Install on one host
For somebody who has the image and no repository. You need Docker with Compose, and nothing else — no Python, no uv, no Node, no browser.
This runs the two mock providers, which is what the first governed action is demonstrated against. It is not a production deployment: read Deployment for the process table, TLS and secret handling before anything real depends on it, and note that the only validated deployment is the Render pilot recorded there.
Every command below runs in a container. Nothing writes outside the directory you choose.
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
The third one ends like this:
[9/9] Two requests governed.
The installation has governed its first actions.
ws01.example EXECUTED INC-1001 execution 5f7c…
ws02.example DENIED INC-1002 execution 9a41…
Under ten minutes from nothing, measured rather than claimed. The fresh-machine job in CI runs exactly these three commands on a clean GitHub-hosted runner, times the wall clock from the first to the end of the third, and fails over ten minutes. The number in the build log is the claim.
What each one does
execbound init /install writes the whole installation into a directory you own: compose.yaml, initdb/, starter-rules.json, a .env with two freshly generated database passwords and the uid and gid it ran as, and private/ holding the gateway's configuration and both providers' account files with fresh continuation keys, dispatch and control tokens and evidence keys. It refuses a directory that already holds an installation, because overwriting would replace tokens the providers and the database already agree on and the failure would appear later as a provider refusing a dispatch. It prints the path and never the contents.
It also asks one question — whether this deployment should send ExecBound a weekly usage report — and takes no for an answer, including the answer a docker run with no terminal gives. --telemetry or --no-telemetry answers it without being asked. Nothing sends anything today: the setting is recorded in private/gateway.json and nothing reads it yet.
-u "$(id -u):$(id -g)" is not optional. The container writes into a directory on your host. Without it the process runs as the image's own user, which cannot write into a directory you own — and a directory Docker creates for a missing mount is owned by root, which it cannot write into either. Running as you is also what lets you read the credential files the third command writes, and it is where the EXECBOUND_UID and EXECBOUND_GID in the .env come from.
docker compose up -d starts five processes: PostgreSQL, the gateway, the endpoint mock, the identity mock and the jobs service. The database creates two roles on its first start: the schema owner (execbound_owner, which migrations and administration use) and the restricted runtime role (execbound_app, which the gateway uses and which cannot bypass row-level security — readiness refuses a role that can). The jobs service waits, saying so, until the third command has issued the credential it runs as.
execbound quickstart applies the schema, then builds the deployment the private configuration already describes: the tenant, its first administrator and a separate administrator for the scheduled jobs, both mock providers as connector accounts, the mapping from endpoint.isolate to the endpoint account's contain operation, an inventory source and an incident source, two endpoints that differ in one fact, an open critical incident for each, both targets provisioned in the provider's own store, the starter rules compiled and activated, an agent with its own credential, and then two requests.
It refuses to run twice against the same database and says what it found rather than changing it.
The one substitution
execbound:community is an image you built or loaded locally. Nothing is pushed to a registry yet (#237 decides the public repository), so there is no docker pull to give you. When there is, EXECBOUND_IMAGE in the .env becomes that reference and nothing else in the installation changes. To build it from a checkout: docker build --tag execbound:community .
Sign in
curl -fsS http://127.0.0.1:8080/health/live
curl -fsS http://127.0.0.1:8080/health/ready
live answers as soon as the process is up. ready checks the schema revision and that the runtime role really is restricted; it does not check provider health, an active bundle or inventory freshness, so a ready gateway can still refuse a particular action for good reasons.
Open http://127.0.0.1:8080/login and sign in with the contents of state/admin.token. Activity shows the two decisions, the agent's page shows its budget meters moving, and Rules (/policy/rules) reads the starter rules back as sentences and lets you edit them there.
All three credentials — the administrator's, the agent's, and the jobs service's map in state/jobs.json — expire after ninety days. Operations says how to issue another.
Why the second request was refused
ws02.example satisfies every condition of the allow rule: it is a workstation, its incident is open, its severity is critical. It is refused anyway, because it is criticality 4 and a denial wins over a permit. A target that simply matched no allow rule would also be refused — that is the default — so a refusal like that would have demonstrated nothing about precedence. This one can only be explained by the deny rule.
A refusal by a deny rule and a refusal by the default report the same reason, NOT_AUTHORIZED. Cedar's denial is a denial, and the event does not name the rule that produced it. What tells you which rule applied is the frozen plan the chain carries beside the decision, read against the rules on the policy page.
The starter rules are four rules and two budgets over endpoint.isolate:
| Allow | when role is workstation, incident_status is OPEN and incident_severity is CRITICAL |
| Deny | when role is domain_controller |
| Deny | when criticality is 4 |
| Review | when is_production is true |
They are demonstration rules over mock providers. Replace them: the action catalog describes a fuller pack, and Policy states what rules can and cannot express. starter-rules.json beside your compose.yaml is the copy to edit; the long way shows how to compile and activate an edited one.
Both decisions, and everything that led to them, are in the audit chain:
docker compose run --rm -v "$PWD:/work" -w /work gateway execbound audit-export \
--tenant <tenant> --credential-file /srv/state/admin.token --output /work/audit.json
The export holds every event of the tenant from genesis: the principals and credentials, the two sources, the mapping, the resources, the import with its manifest digest, the bundle created, compiled, validated and activated, and for each request the frozen plan, the decision and its reasons. The allowed one also carries execution.dispatch_attempt with precondition_class: ENFORCED_BY_PROVIDER, which is the mock stating that it checked the frozen version in the same write that isolated the machine.
The jobs service
The jobs container runs the scheduled work that a deployment needs and that nothing else does: recover every fifteen minutes, notify every two, monitor-process every five. Before this existed the runbook left all three to you, which on most installations meant they were never run at all.
docker compose logs jobs
Each run takes a PostgreSQL advisory lock named for the job and the interval it belongs to, and holds it for the rest of that interval, so a second replica of this service never runs the same interval twice:
docker compose up -d --scale jobs=2
The lock reaches two jobs services. It does not reach a job you run by hand, which takes no lock; see Operations.
Support and versions
Community is complete for one organisation and is supported by the community, with no guarantee:
- Questions and bugs: GitHub Discussions and issues.
- Vulnerability reports:
SECURITY.md. Do not open an issue for one. - Security fixes go into the latest release. There is no long-term support branch; a supported one is part of the Enterprise kit.
- Upgrades are supported from the previous minor version. Going further means stepping through each minor version's migrations in order.
- Running more than one gateway is possible and documented in Deployment. The supported tooling for it — a Helm chart, reference architectures, an air-gapped bundle — is Enterprise.
The long way, one command at a time
Quickstart is a demonstration tenant with mock providers. A deployment that is going to hold your own tenant, your own targets and your own rules does the same work by hand, and these are the commands. They run against an installation execbound init wrote, in the directory it wrote it into, with the database started and nothing else done:
docker compose up -d db
docker compose run --rm gateway alembic upgrade head
run() { docker compose run --rm gateway execbound "$@"; }
Migrations run as the owner, never as the runtime role, and the compose file gives this container that connection. Apply them before starting a gateway of that build: a gateway older than the schema fails readiness rather than serving. The long-running gateway does not hold the owner connection — it starts through execbound.boot, which removes that variable from the environment before the server process is exec'd.
1. A tenant, an administrator and two accounts
The tenant and both connector accounts already have identifiers: execbound init generated them and the private configuration names them, so the rows have to carry exactly those. Read them out of private/gateway.json (connectors[].tenant_id and connectors[].account_id; the file holds tokens, so read the two fields rather than printing it).
run tenant-create --name "Acme" --tenant <tenant>
run principal-create --tenant <tenant> --kind ADMIN
run account-create --tenant <tenant> --provider mock_crowdstrike --actor <admin> --account <endpoint account>
run account-create --tenant <tenant> --provider mock_entra --actor <admin> --account <identity account>
The first ADMIN needs no --actor; every later principal does, because a principal is created by somebody and the audit chain records who.
Then issue that administrator a credential. It is written to a file and never printed:
run credential-issue --tenant <tenant> --principal <admin> --actor <admin> --output /srv/state/admin.token
state/ is mounted into this container, so the file appears beside your compose.yaml and you own it.
2. Register what the agent may act on
A request resolves against the registry, so the registry has to hold the target before an agent can name it. Facts are attested by a source, never by the caller, so the sources come first.
run context-source-create --tenant <tenant> --account <endpoint account> \
--kind inventory --writer inventory_sync --actor <admin>
run context-source-create --tenant <tenant> --account <endpoint account> \
--kind incident --writer soc_analyst --actor <admin>
run mapping-create --tenant <tenant> --account <endpoint account> \
--action endpoint.isolate --actor <admin>
The mapping is what binds the catalog's endpoint.isolate to this account's contain operation. The provider operation, schema version and context contract come from the catalog and never from you, so a mapping can only name something the catalog already binds.
Then your endpoints:
run resource-create --tenant <tenant> --account <endpoint account> --resource-type endpoint \
--provider-id device-ws-01 --source <inventory source> --hostname ws01.example \
--role workstation --environment development --criticality 2 --actor <admin>
Each prints a canonical_target_id. Keep them.
Isolation's contract requires an incident, so each endpoint needs one. Facts and incidents arrive as a manifest, which a source attests and the importer applies in one audited transaction. Write the manifest into the installation directory and mount it:
docker compose run --rm gateway python -c "import uuid; print(uuid.uuid4())"
cat > incidents.json <<EOF
{
"manifest": "execbound.context-manifest.v1",
"tenant_id": "<tenant>",
"account_id": "<endpoint account>",
"source_id": "<incident source>",
"kind": "incident",
"writer": "soc_analyst",
"entries": [
{"provider": "mock_crowdstrike", "resource_type": "endpoint", "provider_id": "device-ws-01",
"incident_id": "<uuid>", "version": "ticket-1001",
"observed_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"facts": {"incident_status": "OPEN", "incident_severity": "CRITICAL"}}
]
}
EOF
docker compose run --rm -v "$PWD:/work" -w /work gateway execbound context-import \
--tenant <tenant> --manifest /work/incidents.json --actor <admin> --strict
--strict refuses the whole manifest if it names a resource the registry does not hold, which is what you want here: the importer never creates resources, so a typo would otherwise be reported and skipped rather than stopping you.
Last, the provider has to hold the same targets the registry does. The mocks keep their own store — that is the point of a provider, and a dispatch whose target the provider has never heard of has nothing to act on. Put the canonical target ids in EXECBOUND_ENDPOINT_RESOURCES in .env as one JSON list, with no spaces inside the JSON, and recreate the provider:
EXECBOUND_ENDPOINT_RESOURCES=[{"tenant_id":"<tenant>","account_id":"<endpoint account>","provider_id":"device-ws-01","canonical_target_id":"<target>","version":"resource-v1"}]
docker compose up -d endpoint
version must be the resource's registry version, resource-v1 unless you passed --version. It is the precondition the provider checks atomically at dispatch: a value the registry does not agree with makes every dispatch for that target fail closed as STALE_AUTHORIZATION, which is the safe direction and an annoying one to debug.
Provisioning at start creates and never overwrites, so restarting a provider later leaves everything the mock has since done to a target alone. Adding a target to the list and restarting provisions only the new one.
A variable you exported wins over
.env, silently. Compose reads the environment of the process before it reads the file, so if you ran something likeset -a; . ./.envyou will finddocker compose up -d endpointprintingRunninginstead of recreating anything.unset EXECBOUND_ENDPOINT_RESOURCESbefore bringing the provider up, and usedocker compose configto see what Compose actually resolved.
3. Write the policy
Nothing is permitted until a bundle is active: the default is refusal, and an installation with no bundle refuses every request. The bundle the kernel reads is compiled Cedar, which you should not have to write. Write rules instead and let the image compile them. starter-rules.json beside compose.yaml is a first draft to edit.
docker compose run --rm -v "$PWD:/work" -w /work gateway execbound ruleset-compile \
--tenant <tenant> --input /work/starter-rules.json --output /work/bundle.json
That reads the tenant's own active mappings, compiles the rules against the contracts those mappings name, and writes a complete bundle document. It writes a file and changes nothing else: no bundle row, no activation. It refuses to overwrite, because the document you activated is the record of what you activated. It prints the bundle_id the next two commands need.
Then the ordinary lifecycle, as an administrator over the runtime connection:
bundle() { docker compose run --rm -v "$PWD:/work" -w /work gateway execbound "$@" \
--tenant <tenant> --credential-file /srv/state/admin.token; }
bundle bundle-create --input /work/bundle.json # DRAFT
bundle bundle-validate --bundle <bundle> # VALIDATED
bundle bundle-activate --bundle <bundle> # ACTIVE
Validation recompiles the rules the document carries and refuses content that disagrees with them, so a hand-edited bundle is refused whichever way it was written. That check is why the compiler is allowed to sit outside the enforcement boundary at all.
4. Govern one action
An agent is a principal with its own credential, never yours:
run principal-create --tenant <tenant> --kind AGENT --actor <admin>
run credential-issue --tenant <tenant> --principal <agent> --actor <admin> --output /srv/state/agent.token
Now act as that agent:
TOKEN=$(cat state/agent.token)
curl -sS -X POST http://127.0.0.1:8080/v1/execute \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"operation_ref":"endpoint.isolate","target":{"kind":"hostname","value":"ws01.example"},
"arguments":{},"reference":"INC-1001","claimed_context":{"incident_id":"<uuid>"}}'
{"execution_id":"...","state":"EXECUTED","plan_hash":"...","approval_id":null,"approval_state":null}
A target that a deny rule covers answers the same shape with "state":"DENIED" and leaves the provider's row untouched.
Upgrade to a newer image
Load or pull the new image, point EXECBOUND_IMAGE in .env at it, apply its migrations with the owner connection, then replace the processes:
docker compose run --rm gateway alembic upgrade head
docker compose up -d
Migrations first, always. A gateway older than the schema fails readiness rather than serving, which is the safe direction; a gateway newer than the schema is the one that would misread rows, and applying first is what prevents it. Downgrades that would drop history are refused by design. Upgrades are supported from the previous minor version; going further means stepping through each minor version's migrations in order.
Back up, and prove the backup restores
Two things have to be kept together: the database, and the provider stores. A provider that loses its store loses the journals that settle retained uncertainty, so an uncertain operation would have nothing to be reconciled from.
POSTGRES_PASSWORD=$(grep '^POSTGRES_PASSWORD=' .env | cut -d= -f2-)
mkdir -p backups
docker compose exec -T -e PGPASSWORD="$POSTGRES_PASSWORD" \
db pg_dump -Fc -U execbound_owner execbound > backups/execbound.dump
tar -czf backups/providers.tar.gz providers
A backup nobody has restored is a hope. Restore it into a second database on the same instance and verify it:
docker compose exec -T -e PGPASSWORD="$POSTGRES_PASSWORD" \
db createdb -U execbound_owner execbound_restore
docker compose exec -T -e PGPASSWORD="$POSTGRES_PASSWORD" \
db pg_restore -U execbound_owner -d execbound_restore < backups/execbound.dump
mkdir -p reports
docker compose run --rm -v "$PWD/reports:/out" gateway python -m execbound.recovery \
--restored-database execbound_restore --compare-live --output /out/restore-report.json
It prints one line per tenant and writes a JSON report:
Restore verification passed: 8af26def events=3 chain=ok checkpoint=NO_CHECKPOINT live=MATCHES_LIVE unresolved=0
chain=ok means the audit chain verifies from genesis to the stored head in the restored copy. live=MATCHES_LIVE means that head is the one the live database holds, so the backup is current rather than merely readable. The verifier writes to a new file only and never touches either database. Retention, backup and recovery explains what it checks and what it does not.
Drop the copy when you are done:
docker compose exec -T -e PGPASSWORD="$POSTGRES_PASSWORD" \
db dropdb -U execbound_owner execbound_restore
Reinstalling, and running two of them
docker compose down keeps the database volume, so a second attempt with a new .env authenticates against the roles the first attempt created and fails with password authentication failed several steps later. To start genuinely fresh, take the volume with it: docker compose down -v, and run execbound init into a new directory.
To run a second ExecBound on this host, give it a different EXECBOUND_PROJECT: the project names the volume, so two installations sharing a name share a database.
What this run established
The three commands were run end to end on one machine against a freshly built image and an empty database, and the wall clock from the first command to the end of the third was recorded. The gateway reported ready, the administrator token signed in, and the two requests answered EXECUTED and DENIED, with the mock's store holding ws01.example contained and ws02.example untouched. The jobs service ran each of its three jobs once, and a second replica of it ran none of them a second time in the same interval.
The long way, the upgrade and the restore verification were exercised separately against a populated install in the runs recorded in the installation trial and the first-action trial, whose defects are fixed above: the database was addressed by container hostname, which the gateway refuses as remote without TLS; the containers ran as the image's own user, which could not read material the operator owns; the provider stores were a named volume, which Docker creates root-owned so a non-root process could never write to them; and a variable exported into the shell silently overrode .env.
The largest gap is the same in all of these: Docker was the host's, and the host is a machine this project is developed on. What a genuinely fresh machine does is the fresh-machine job's answer, and the record of this release's own run is the quickstart trial. No run so far has used a published or signed image, a real provider, the identity path, or an approval.