Self-hosting
Run the server, inbox and Postgres for your team with the one-file Docker Compose bundle.
Intentlock runs as one deployment per team. The self-host bundle is a single Compose file with Postgres, the server, the inbox and nginx in front of them. The quick start uses the same bundle; this page covers a real install.
You need Docker with Compose v2.23 or newer and openssl.
Install
Section titled “Install”BASE=https://raw.githubusercontent.com/AbdullahMukadam/intentlock/main/deploy
curl -fsSLO $BASE/docker-compose.selfhost.yml
curl -fsSL $BASE/selfhost.env.example -o .envEdit .env before the first start:
ORG_NAME, andPUBLIC_URLset to thehttps://address your team opens. Session cookies are then marked secure; invite links and OAuth callbacks use it.SMTP_URLandSMTP_FROMto email invites and hold notifications, and to enable “forgot password”. Without them, links are only shown to copy.HOLD_WEBHOOK_URLto post new holds to a Slack or Discord channel.GOOGLE_CLIENT_IDandGOOGLE_CLIENT_SECRET,GITHUB_CLIENT_IDandGITHUB_CLIENT_SECRETfor “Continue with Google” and “Continue with GitHub”. Only existing and invited users can sign in this way, matched by verified email. Register the callback<PUBLIC_URL>/api/v1/auth/oauth/<google|github>/callback.INTENTLOCK_VERSION: the release to run (defaults to the one this bundle came with; see the changelog).
Every variable is listed in Environment variables. Then add the two secrets and start:
echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)" >> .env
echo "SIGNING_KEY=\"$(openssl genpkey -algorithm ed25519)\"" >> .env
docker compose -f docker-compose.selfhost.yml up -d
docker compose -f docker-compose.selfhost.yml logs server | grep "setup code"Open PUBLIC_URL, paste the setup code and create the first admin. Then invite your team, and create an agent and an API key for each agent app. Agent apps use INTENTLOCK_URL=<PUBLIC_URL>/api.
How the bundle is laid out
Section titled “How the bundle is laid out”nginx serves everything from one origin:
/api/v1/*goes to the server’s/v1/*./api/socket.iogoes to the server’s WebSocket, for live holds and activity.- Everything else goes to the inbox.
One origin means session cookies work with SameSite=Lax, there is no CORS, and the browser never talks to the server’s port directly. nginx compresses API responses, and the server runs with TRUST_PROXY=1 so rate limits and the audit log see the client’s IP.
The images come from GHCR (ghcr.io/abdullahmukadam/intentlock-server and intentlock-inbox, tag INTENTLOCK_VERSION). The compose project is named intentlock-selfhost. To build the images from a clone instead:
docker compose -f deploy/docker-compose.selfhost.yml --env-file deploy/.env \
up -d --buildYour responsibilities
Section titled “Your responsibilities”- TLS in front of port 8080 (
HTTP_PORT). - Backups of Postgres (the
pgdatavolume). - The signing key. Keep
SIGNING_KEYsecret and backed up. Losing it invalidates every signed plan; leaking it lets anyone sign plans.
Upgrading
Section titled “Upgrading”Set INTENTLOCK_VERSION to a newer tag and run up -d again. Migrations run first as a one-off migrate service, before the server starts, and are backward compatible with the previous release.
The signing key
Section titled “The signing key”SIGNING_KEY signs plans. It only protects plans that are running right now: tokens live 15 minutes by default and 60 at most. Users, agents, policies and the audit chain do not depend on it, so a new key never loses data.
Backup
Section titled “Backup”Keep the PEM in your secret store (a password manager or your platform’s secrets), not only in .env on the server. Every server instance must use the same key. To make one:
npx -y @intentlock/cli keygen --out ./keys --envPlanned rotation
Section titled “Planned rotation”Rotate once a year, or when someone with access to the key leaves.
- Create a new key with
npx -y @intentlock/cli keygen. - Deploy with the new key in
SIGNING_KEYand the old key inSIGNING_KEY_PREVIOUS. The old value may be the private key or only its public half. The server signs with the new key and publishes both public keys at/v1/keys/jwks, so running plans keep working. - Wait at least 2 hours: a token lives up to 60 minutes, and the guard can refresh it (re-signed with the new key) up to 60 minutes after it expires. If agent apps in hybrid mode may be offline longer, wait until they have reconnected: their queued decisions are re-checked with the token they were made under, and after the old key is gone those decisions are flagged as mismatches in the audit log.
- Remove
SIGNING_KEY_PREVIOUSand deploy again.
Right after step 2, an agent app that fetched the public keys less than 30 seconds earlier can block a call with invalid_token once. It fetches the new keys on the next attempt.
If the key leaked
Section titled “If the key leaked”Anyone with the key can sign plans, so skip the overlap:
- Create a new key and deploy it in
SIGNING_KEYwith noSIGNING_KEY_PREVIOUS. The server refuses old tokens at once. - Restart your agent apps. In hybrid mode they keep a copy of the public keys for up to 10 minutes and would trust the old key until then.
- Running plans fail with
invalid_tokenand their calls are blocked. Agents start a new run with a new plan. - Check the audit log for flagged entries from the time of the leak.
If the key is lost
Section titled “If the key is lost”Deploy a new key the same way. Running plans are blocked and agents start new runs; nothing else is affected.
License file
Section titled “License file”Without a license you run the Community edition. To use one, put the file in ./license/ and set LICENSE_FILE=/license/<file>. See Licensing.
More than one server instance
Section titled “More than one server instance”The server is stateless: counters use atomic SQL updates, holds move out of pending exactly once, and audit appends take a database lock. You can run several instances behind a load balancer against one Postgres. Live WebSocket events are not yet shared across instances, so either use sticky sessions or rely on the inbox’s 10-second polling for new holds.