Architecture
How the gateway, frontend, backend and Ansible executor fit together, and where your secrets live.
Playbook Runner is a monorepo with three services behind a Caddy gateway, plus PostgreSQL. Only the gateway publishes a port, so the browser sees a single origin and the executor that touches your hosts is never reachable from outside.
| Service | Stack | Port | Role |
|---|---|---|---|
| Gateway | Caddy | 80 (published), 50050 (internal) |
Public HTTP entry point and internal gRPC router. |
| Frontend | Astro SSR, React | 4321 (internal) |
The web interface. |
| Backend | Bun, Hono, oRPC, Better Auth, Drizzle | 3000 (internal) |
Authentication, the API, business rules, the scheduler. Owns the database. |
| Executor | Python, FastAPI, gRPC, ansible-runner | 50051 (internal) |
Runs Ansible. Has no database. |
| PostgreSQL | PostgreSQL 17 | 5432 (internal) |
Users, inventory, credentials, playbooks, jobs and history. |
Request paths
The gateway sends /rpc, /api, /scalar and /openapi.json to the backend and every other path to the frontend.
The backend reaches the executor over gRPC through the gateway’s internal router on :50050, which routes by proto package: /run.* goes to the executor on :50051, anything else answers UNIMPLEMENTED.
Anatomy of a run
- You press Run in the browser. The frontend calls the backend over oRPC and keeps the response open as an event stream.
- The backend checks your session, then resolves the run against its database: the playbook content (or, for a Git playbook, the repository and the commit it was synced at), the deduplicated list of hosts and, for each one, its SSH user and decrypted private key.
- It sends that complete payload to the executor in one server-streaming gRPC call, authenticated with the shared
SERVICE_TOKEN. - The executor writes a temporary inventory and key files (and, for Git playbooks, exports the pinned commit from its mirror), then hands them to
ansible-runner. - Every event Ansible emits travels back on the same gRPC call, and the backend forwards it to the browser. The backend records the run in the history.
- When the run ends, or you close the tab and the call is cancelled, the executor stops Ansible, kills leftover processes and deletes the run’s key files.
Scheduled jobs follow the same path, started by the backend’s in-process cron loop instead of a browser.
Security model
- Private keys never reach the browser. They are encrypted at rest with AES-256-GCM using
CREDENTIALS_ENCRYPTION_KEY, never returned by the API, and decrypted only while the backend builds a run. - The executor trusts only the backend. Every gRPC call must carry
SERVICE_TOKEN, and the executor is not published on any host port. - Host keys are verified according to
SSH_HOST_KEY_POLICY(trust on first use by default). - User input is constrained. Extra variables may not start with
ansible_(the executor checks this again), and device and group names are restricted to letters, digits,.,_and-. - Runs are bounded. At most
MAX_CONCURRENT_RUNSAnsible processes run at once, extra requests are refused with too many requests, a job never overlaps with itself, andforksis capped at 50. - Git is constrained. Repositories sync only over
httpsandssh, with no global Git configuration, a per-command timeout and a size limit. A repository’s ownansible.cfgis ignored.
The API
The API is versioned under /rpc/v1 (oRPC, used by the frontend) and /api/v1 (OpenAPI). The interactive reference lives at /scalar and the raw specification at /openapi.json.
From scripts, authenticate with a personal API key, created on the API keys page and sent as the x-api-key header:
curl -H "x-api-key: $PLAYBOOK_RUNNER_KEY" https://ansible.example.com/api/v1/playbooks/list