Skip to main content

The image and its processes

This guide covers the container image and the order of operations for running the gateway and the two protected mock providers outside the local demonstration harness. It describes what the image contains and how to place configuration, secrets, the database, TLS termination and health probes around it. It does not describe a validated public deployment: no hosted gateway has been operated, and public deployment or real vendor connectivity still needs separate authorization (see Operations and the threat model).

The Community install

One host, Docker and nothing else, in three commands:

docker run --rm -u "$(id -u):$(id -g)" -v "$PWD/execbound:/install" execbound:community execbound init /install
cd execbound && docker compose up -d
docker compose run --rm gateway execbound quickstart

execbound init writes the compose file, the role script, the starter ruleset, a .env with two generated database passwords, and the private configuration with fresh continuation keys, per-provider dispatch and control tokens and evidence keys. docker compose up -d starts PostgreSQL, the gateway, both mock providers and the jobs service. execbound quickstart applies the schema and builds the deployment the private configuration describes, ending with two governed requests, one executed and one refused. Install is the runbook, including the same work one command at a time for a deployment that wants its own tenant rather than the demonstration one.

The target is under ten minutes from nothing to a first governed decision, and it is measured rather than asserted: the fresh-machine job runs the three commands on a clean GitHub-hosted runner, times them and fails over ten minutes.

One substitution. execbound:community is built locally (docker build --tag execbound:community .). Nothing is pushed to a registry until #237 creates the public repository, so there is no pull reference to give; when there is, EXECBOUND_IMAGE in the written .env becomes it and nothing else changes.

The installation runs the two mock providers. It is complete for one organisation, which is what Community is (#411), and it is not a production deployment: the rest of this document is the process table and the contract a real one must express.

The image

Dockerfile builds one image for every ExecBound process; the command selects the role. The build argument EXECBOUND_COMMIT records the source commit in the image, and /health/ready reports it as commit beside the package version; a build that omits it reports the platform's own RENDER_GIT_COMMIT where one is injected, and null otherwise. CI passes the checked-out commit.

  • Built from the locked environment only. A builder stage installs the runtime dependency group from uv.lock with uv sync --locked --no-dev --no-editable and installs the package itself as a wheel. Development tools, test fixtures and browsers are not present. The wheel contains only execbound; the synthetic packs and validation harness in src/execbound_harness are never packaged.
  • The console is built in a stage that ends before the image. A Node stage (node:24.21.0-bookworm-slim, pinned by digest) enables corepack, installs the console's locked tree with pnpm at the exact version and hash console/package.json names, under a seven-day minimum release age and with no dependency install scripts, and runs the Vite build inside its size budget. The builder copies only the built files into src/execbound/surfaces/console_dist before it installs the package, so the wheel and the image carry them; the runtime stage never holds Node, pnpm or node_modules, which the smoke test's test ! -e /build and uid checks keep true. The console is the surface a person opens and is not optional: every console route answers 503 with a plain sentence where the package carries no build, so an image built without the Node stage is a deployment nobody can sign in to (surfaces).
  • Pinned bases. The Python base and the uv binary are pinned by digest. Dependabot proposes digest updates through the docker ecosystem in .github/dependabot.yml; treat those like any other dependency change and let CI rebuild the image.
  • Non-root. The runtime stage creates user execbound (uid 10001) and runs as it. Nothing under /opt/execbound or /srv/execbound is writable by that user, and no process writes there. The user has /bin/sh as its login shell and an empty ~/.ssh (mode 0700), which Render's SSH and dashboard shell need to open an operator session as a non-root user. The image runs no SSH server.
  • No configuration or secrets in layers. .dockerignore is an allowlist: only pyproject.toml, uv.lock, src/, alembic.ini, migrations/, the client package's sources and console/ (its sources, never its node_modules or a local build) enter the build context. .env files, local-data/, private fixtures and caches cannot be copied in by mistake.
  • Migrations travel with the image under /srv/execbound/migrations so the owner connection can apply them from a one-off container of the same build.
  • Health check. Every process listens on port 8000 inside its own container, so the image's HEALTHCHECK is a TCP connection to that port and fits the gateway and both providers. Orchestrator readiness for the gateway should use GET /health/ready (below).

Build and inspect:

docker build --build-arg EXECBOUND_COMMIT=$(git rev-parse HEAD) --tag execbound:local .
docker run --rm execbound:local id -u
docker run --rm execbound:local execbound --help

CI builds the image on every push and pull request, in the same job as the tests, and smoke-tests it without a database: uid 10001, the login shell and private ~/.ssh with no SSH server, the CLI help, alembic history, GET /health/live answering ok, GET /health/ready answering 503 while no database is reachable, and no private files inside the image.

Processes and their inputs

ProcessCommandInputs
Gatewaythe image default (uvicorn execbound.app:create_app --factory --host 0.0.0.0 --port 8000)DATABASE_URL for the restricted runtime role; EXECBOUND_RUNTIME_CONFIG pointing at the private gateway JSON mounted read-only
Endpoint mock provideruvicorn execbound.provider.mock_app:create_mock_app --factory --host 0.0.0.0 --port 8000EXECBOUND_MOCK_PROVIDER=mock_crowdstrike, EXECBOUND_MOCK_ACCOUNTS_PATH (private accounts file, read-only), EXECBOUND_MOCK_DATABASE_PATH (SQLite store on a persistent volume)
Identity mock providersame commandEXECBOUND_MOCK_PROVIDER=mock_entra and its own accounts file and store
Arcade hook surfaceuvicorn execbound.arcade.boundary:create_arcade --factory --host 0.0.0.0 --port 8000The same DATABASE_URL and the same EXECBOUND_RUNTIME_CONFIG file the gateway reads. Run it only where the private configuration carries an arcade block
Jobs serviceexecbound jobsDATABASE_URL, EXECBOUND_RUNTIME_CONFIG, and a state directory holding jobs.json and monitor.json. Runs recover, notify and monitor-process on their intervals, each behind an advisory lock held for the interval; see Operations
Migration (one-off)alembic upgrade headMIGRATION_DATABASE_URL for the schema owner
Administration (one-off)execbound <command> per OperationsMIGRATION_DATABASE_URL for owner commands, DATABASE_URL plus a mounted ADMIN credential file for bundle commands

The gateway JSON has the shape documented in Surfaces. Each connector's origin must name the provider as the gateway resolves it. The connector refuses cleartext to anything but a loopback IP address (ConnectionConfig in provider/connector.py), so a provider on another host, even on a private network, must serve HTTPS with a certificate the gateway can verify; an http:// origin works only for a provider on the same host as the gateway. This is a decision rather than a gap (#183): there is no opt-in for cleartext on a private network, and the Render pilot runs the mocks as public HTTPS web services with bearer authentication for that reason. Dispatch and control tokens and evidence keys must differ between provider families; startup refuses reuse.

The Arcade hook surface is a second public process, and it costs two things worth writing down. It holds a runtime, not a loopback intake, so it opens its own connection pool against the same database: the pooler has to have room for the gateway's pool and this one, which is the pilot's practical limit on running both. And the two processes must be given the same private configuration file, or they will disagree about which tools are governed — a disagreement nothing downstream would notice, because each process answers correctly for the map it holds. It serves POST /pre, POST /post and GET /health and answers 404 to every other method and raw path it holds; GET /health is not a second readiness implementation but the application's own /health/ready under a shorter path, which is what Arcade's configuration and Render's health check ask for. render.yaml expresses it as the execbound-arcade web service.

Provider stores must persist: a provider that loses its SQLite store loses the journals that settle retained uncertainty. Mount EXECBOUND_MOCK_DATABASE_PATH on a volume and back it up with the gateway database as Retention, backup and recovery describes.

Order of operations

  1. Provision the database roles. Locally, scripts/local_setup.py creates the owner and restricted runtime roles and both databases; for the hosted project, follow Supabase. The runtime role must own no tables and must not be able to bypass row-level security; GET /health/ready refuses a role that can.

  2. Apply migrations with the owner connection from a one-off container of the exact image you will run:

    docker run --rm -e MIGRATION_DATABASE_URL=<owner URL> execbound:local alembic upgrade head

    Apply migrations before starting a gateway of that build. Downgrades that would drop history are refused by design.

  3. Start the providers with their accounts files and persistent stores. A provider on the gateway's own host may use a cleartext loopback origin; any other provider, on a private network or not, serves HTTPS with a certificate the gateway verifies (#183). Every provider endpoint authenticates its caller, so where a provider is reachable from is a matter of exposure, not of authority.

  4. Generate the gateway configuration with independent continuation keys and per-provider credentials, and mount it read-only at the path EXECBOUND_RUNTIME_CONFIG names. The origin field is the public HTTPS origin clients and approvers will use (see TLS below).

  5. Start the gateway and wait for GET /health/ready to answer ready. Readiness checks the schema revision and the runtime role's restricted privileges; it does not check provider health, an active bundle or inventory freshness, so a ready gateway can still refuse a particular action.

  6. Administer tenants, credentials, approvers and bundles with one-off containers running the execbound commands; issued tokens are written to files you mount, never printed.

Rolling a new image: build, apply its migrations with the owner connection, then replace the processes. A gateway older than the schema fails readiness rather than serving.

Run more than one gateway

Community may run several gateways behind a load balancer, and the supported tooling for it — a Helm chart, reference architectures, an air-gapped bundle — is Enterprise (#411). This section is published because the replica-safety test passes; it would say pending and name the test if it did not.

What is proved. tests/test_replica_safety.py runs two gateways, each with its own engine and connection pool, against one PostgreSQL and one tenant. Six concurrent admissions at a ceiling of three admit exactly three, and the other three are refused rather than lost; one approved execution retried six times across both gateways issues exactly one dispatch ticket and charges exactly one unit of impact. The shared state is in the database and both replicas reach it through the same fences.

What is not. Those are two runtimes in one process, not two operating system processes, so a defect that lived in a module global would not be caught by it. No multi-replica deployment has been operated. The recovery-time measurement for a tested high-availability arrangement is #130 and is not this.

The arrangement:

PieceHow manyNotes
GatewaySeveralStateless. Every replica reads the same private configuration file; two files that disagree would disagree about what is governed, and each would answer correctly for the map it holds.
Jobs serviceAny number of replicasEach job's interval is taken by one replica and held for that interval; see Operations.
PostgreSQLOneEvery fence, budget, claim and approval lives here.
Load balancerOneTerminates TLS and passes Host through unchanged; the configuration's origin is the public HTTPS origin, and the gateway refuses a request whose Host is not its netloc.
ProvidersAs the process table aboveA provider on another host serves HTTPS with a certificate the gateway verifies.

Three things to get right:

  • Migrations run once, before the new gateways start. A gateway older than the schema fails readiness rather than serving; roll the schema first, then the replicas.
  • The request limits key their window by the direct peer address. Behind a proxy every request shares that key on each replica, and the windows are per replica rather than per deployment, so set limits.client_requests for the proxy's aggregate and rely on the per-credential and per-sign-in windows for individual callers.
  • The jobs service wants a direct connection, not a transaction-mode pooler. Its advisory locks are session-scoped; a pooler that hands the session to somebody else between statements loses them.

Managed PostgreSQL works and is the usual choice: Amazon RDS, Azure Database for PostgreSQL Flexible Server, Cloud SQL, or Supabase as the hosted service uses it (Supabase). Remote URLs must carry sslmode=verify-full with a mounted CA path, which the gateway enforces rather than recommends.

TLS termination and origin

The gateway serves cleartext on its container port. Terminate TLS at a reverse proxy or load balancer in front of it and pass the Host header through unchanged. The configuration's origin must be exactly the public HTTPS origin: the human surface compares the Host and Origin headers against it, sets __Host- prefixed secure cookies and HSTS only when the origin is HTTPS, and MCP clients are told that origin for approvals.

The request limits key the per-client window by the direct peer address and trust no forwarding headers. Behind a proxy every request shares that key, so set limits.client_requests in the gateway configuration for the proxy's aggregate rate rather than for one caller, and rely on the per-credential and per-sign-in windows for individual callers; see Operations.

Health probes

ProbeRouteMeaning
LivenessGET /health/liveThe process answers; no dependency is checked.
ReadinessGET /health/readyPostgreSQL is reachable at the expected schema revision with a correctly restricted runtime role. Returns 503 otherwise.
Arcade readinessGET /health on the hook processThe same answer as GET /health/ready, under the path the hook contract declares: 200 with the database up, 503 with it down, unauthenticated and naming no tenant.
Container HEALTHCHECKTCP connect to port 8000Generic for gateway and providers; use the routes above for the gateway.

Providers expose no health route beyond the TCP check; the gateway reports provider failures per execution as retained uncertainty, not as readiness.

Secrets

  • DATABASE_URL and MIGRATION_DATABASE_URL carry passwords. Supply them through the orchestrator's secret mechanism or a mounted file the process reads at start, never through image layers, build arguments or shell history. Remote URLs must use sslmode=verify-full with a mounted CA path.
  • The gateway JSON and the provider accounts files contain dispatch and control tokens and evidence keys. Mount them read-only from a secret store, owned by root and readable by uid 10001 only.
  • Issued agent and human tokens are written once by credential-issue to a file you choose; hand that file to the agent or approver through your own channel and delete it.
  • Nothing in this repository rotates keys automatically; see #50.

Release record, provenance and rollback

Every CI run writes local-data/release/ and retains it for 90 days as the release-provenance artifact:

  • sbom.cdx.json — a CycloneDX 1.6 SBOM of the application virtual environment the image runs from, generated with cyclonedx-bom==7.1.0 against a uv sync --locked --no-dev --no-editable environment built beside the image. This is the set the application imports; it is deliberately not every Python distribution present in the image. The base layer also carries pip and its vendored packages under /usr/local/lib/python3.13, which the application never executes and which this SBOM does not list. The scheduled image scan covers the whole filesystem and is the place those appear.
  • provenance.json — the source repository, revision and ref; the workflow and run; SHA-256 digests of uv.lock, pyproject.toml and the Dockerfile; both digest-pinned base images read out of the Dockerfile; the built image's id and the SBOM's digest; and a licence disposition derived from the SBOM.
  • image-distributions.json — the distributions the built image actually carries, read out of the image itself.

The job compares the last two and fails on any difference, so an SBOM that does not describe what shipped cannot be retained as if it did; provenance.json records the comparison as sbom_matches_image with the drift in both directions. A comparison that could not be made is recorded as unknown, never as a match.

The licence disposition names every component whose licence is undeclared or copyleft, so neither can enter a release unnoticed. Current dispositions:

ComponentLicenceDisposition
psycopg, psycopg-binaryLGPL-3.0-onlyUsed as an unmodified library over its public API. Distributing the image would oblige us to offer the library's source and permit relinking; decide that with the distribution boundary in #122.
certifiMPL-2.0File-level copyleft on unmodified files only; no obligation while the file is unmodified.
cedarpyUndeclared in metadataApache-2.0 in the LICENSE file the wheel ships; the package declares License-File without an SPDX identifier, so tooling cannot read it.
execbound-clientUndeclaredOurs. The client package, in the image only so the showcase worker can run execbound-demo (Operations). Its licence is settled with the server's in #122, so it carries no licence metadata yet either.
execboundUndeclaredOurs, and genuinely undecided. The Community licence and distribution boundary is #122; until it is settled the wheel carries no licence metadata, and this document does not invent one.

What a release and a rollback would have to record

Nothing is deployed and no registry receives this image, so there is no deploy approval, environment record or rollback reference to link, and provenance.json records both as explicit gaps rather than omitting them. When a deployment exists, the chain it must record is: the change record and its pull request and reviewer; the CI run whose checks passed; the artifact digest and its SBOM; the approver, scope and approval time; the environment, operator, time and result; and the rollback version, trigger, authorization and verification. An approved rollback restores a prior image but cannot undo consequential actions already dispatched — reconciling those is a separate governed step, not part of the rollback.

Signing

There is no release signing and no build attestation. The image is identified by the local image id its build produced; there is no registry digest because nothing is pushed. Evaluating a signed release is a roadmap task, not an existing capability, and no part of this repository should be read as claiming one.

What enforcement is missing

None of the checks above blocks anything. Branch protection is unavailable on the repository's plan, so a failing check does not prevent a merge and a direct push to main is not rejected. The provenance document records which checks ran, not that they were required.

The documentation site

docs.execbound.ai is a second Render static site, execbound-docs in render.yaml, beside the one-page marketing site (#418). It serves the repository's own Markdown: docs-site/publish.json names every file that goes public, docs-site/scripts/prepare.mjs writes the tree Docusaurus builds, and the API reference is rendered by Scalar from console/openapi/console-v1.json. Nothing is copied into the site directory and committed, so a documentation change is a change to the document. docs-site/README.md describes the manifest, the scrub and the link rewriting.

The build is Node only. buildCommand enables corepack's pnpm shim in a directory under $HOME, puts that directory first on PATH, then runs pnpm install --frozen-lockfile && pnpm build in docs-site. NODE_VERSION pins the Node the console and the image use, and corepack takes pnpm from the packageManager field. The directory is not optional: Render's Node image carries its own pnpm in a read-only /usr/bin, and a plain corepack enable fails there with EROFS: read-only file system, unlink '/usr/bin/pnpm', which is how the service's first build failed on 20 September 2026. The shim leads PATH for the command, but a pnpm started from inside a package script still found the image's 10.34.5 and failed the engines.pnpm check, so docs-site's build, start and typecheck scripts run the prepare step with node instead of calling pnpm sources. Nothing Python runs on Render: the prepare step is a Node script with no dependencies. buildFilter limits the service to changes under docs-site/, docs/, the console tokens, fonts and OpenAPI document, and the two favicons the marketing site carries.

Automatic deploys are off, as they are for every service in the file, so a documentation change reaches docs.execbound.ai only after a manual deploy, which is the hosted lane's step.

Its Content Security Policy is set in render.yaml, because a static site has no application to set headers from:

default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';
img-src 'self' data:; font-src 'self'; connect-src 'self';
form-action 'none'; frame-ancestors 'none'; base-uri 'none'

Stricter than a default and looser than the marketing page's default-src 'none'. The two 'unsafe-inline' relaxations are stated rather than left to be discovered: Docusaurus writes two inline scripts into every page and a static host offers no nonce, and every OpenAPI renderer surveyed injects inline style. connect-src 'self' is the directive that matters: nothing the page runs may reach another origin, and in Chromium on 19 September 2026 it refused Scalar's own call to api.scalar.com while every other request stayed on this origin.

Going live, in order

  1. Merge the blueprint. The execbound-docs entry reaching main is what lets Render's blueprint sync create the service. It creates nothing else and changes no existing service.
  2. Sync the blueprint in the Render dashboard for the workspace that runs execbound-site. The first sync creates the service; confirm the build command, the publish path ./docs-site/build and the three headers before approving. (What happened: the automatic sync created it on 20 September 2026 and its first build failed as described above; the blueprint's automatic sync has been off since 21 September, so a sync is now always this manual step.)
  3. Add the custom domain. docs.execbound.ai on execbound-docs, added through the Render API or dashboard. Render answers with the target to point at; the zone gets one CNAME record for docs naming exactly that target, with a 600-second TTL, written through the registrar's own CLI after a dry run. The apex and www belong to the marketing site and are not touched, and app.execbound.ai belongs to the gateway.
  4. Deploy manually and wait for Render to verify the domain and issue a certificate.
  5. Verify: https://docs.execbound.ai/ answers and shows the four sections; the console API page renders 65 paths; the response headers carry the policy above; a documentation page's internal links work; and the browser console shows no request to another origin.
  6. Then, and only then, point the public site's links at it. Six links on execbound.ai name documents inside the private repository and answer 404 today. That change is its own small pull request, after this domain answers, which is the sequence the founder recorded on #418 on 20 September 2026.

Validated deployments

DateWhereCommitWhat was validatedRecord
September 16 to 18, 2026Render (Virginia): gateway web service on https://app.execbound.ai from September 17, two mock web services with disks, three cron jobs; hosted Supabase through the session pooler at 0024_monitor_rls_initplan, then at 0027_approval_deadline from September 17 with the services on 80c5555, then at 0029_claim_clock later that day with all seven services on 6db3782 (the execbound-sandbox-refresh cron added by the blueprint sync) and the pilot tenant adopted as sandbox slot 1, then the first batch of five sandbox slots seeded and merged into the configuration with the web services redeployed pinned to the same commit, then at 0030_scout_agents with all seven services on f68f819, then at 0031_scout_rules with the three web services pinned to dd7d324 (the sandbox demo kit live: the welcome page as a progress page, the two downloads, execbound-demo) while the cron jobs run the head of main at 04dfa94 and skip every tenant until 0032_connector_precondition is applied, then at 0032_connector_precondition (September 18, 2026) with the three web services pinned to 3c15197, readiness reporting version and commit, and every cron job healthy again at 04dfa94, then at 0034_agent_owner_org the same day with the three web services pinned to 0307b32 while the cron jobs, still at 04dfa94, skipped or failed every run, then at 0035_session_label ten minutes later with all seven services on 7e4ac15 and every cron job healthy, then at 0036_people the same day with all seven services on 1bf37cb, then at 0037_agent_bindings with the web services on 9dff1d8 and the cron jobs on 5114ca01830fda (boot module), 6ed2ad3 (cron limits), 80c5555 (approval deadline), 6db3782 (sandbox onboarding), f68f819 (Scout schema), dd7d324 (demo kit), 3c15197 (readiness identity), 0307b32 (reported failures, Arcade correlation, agent owner organization), 7e4ac15 (settings pages), 1bf37cb (People page, email, Slack and Teams delivery), 9dff1d8 (Agents bindings, invitations by email)Readiness through the pooler with the pinned CA bundle; both mocks refusing unauthenticated calls and accepting their dispatch tokens with resources provisioned at start; every cron job completing; the six runbook scenarios in OBSERVE mode (29 decisions) and in ENFORCE mode with real mock mutations, two founder approvals from the console consumed within their deadlines, and the provider refusing a second action on a mutated target until the inventory carried the new versionRender pilot; #180

A pilot with synthetic data against mock providers is not a production deployment: no real vendor, no real tenant data, no log forwarding, no backups of the hosted database, and the private files travel as environment variables. Those remain deployment work and the threat model's R11 says so.

Not covered

  • A host that cannot mount secret files can hand the private configuration, a mock's accounts and resources files, the ADMIN token and the database CA bundle over as environment variables and start every process with python -m execbound.boot <command>: the module writes them to owner-only files under /tmp/execbound (or EXECBOUND_BOOT_DIR), provisions the mock store, scrubs the values from the environment and execs the command without a shell, expanding $NAME arguments itself. It refuses malformed material before starting anything.
  • A Render blueprint (render.yaml at the repository root) expresses the process table for the Stage 1 pilot; Render pilot is its runbook. The one-host Compose file execbound init writes is the other expression of it; Kubernetes manifests and a Helm chart are not provided and are Enterprise (#411), and the process table above is the contract they must express.
  • Scheduling recover, notify and monitor-process is the jobs service's job in a Compose install and the cron jobs' in the Render blueprint. Anywhere else it is an operator's cron entry, systemd timer or container job with the same image, private configuration and an ADMIN credential file; see Operations and Recover an uncertain execution.
  • Backups, retention and the restore verifier are described in Retention, backup and recovery; scheduling them is the operator's job. Key rotation is a private-configuration reload described in Operations (#50) and security event export (#45) remain open.
  • The providers are controlled mocks. A real CrowdStrike or Entra integration is a separate unit with its own evidence.
  • The only validated deployment is the Render pilot above, with synthetic data and mock providers; building and running this image locally does not add to that.