A self-hosted web UI for managing and running Ansible — a simpler, friendlier AWX
that runs from a single docker compose up, with no Postgres, Redis or Receptor.
What makes it different is the AI layer that checks its own output. Most tools
either don't have AI or trust it blindly. Playforge generates playbooks from plain
language, then verifies them: it flags modules that don't exist, catches logic
mistakes a model misses (SSH/UFW lockout, destructive ops, malformed vars), and
grounds every answer in your real modules and files — fully offline.
Projects & runs
- Import an existing Ansible project from a local path, a
.zip, orgit clone(Gitea/GitHub) — junk like.venv/and caches is filtered out automatically. - Every project is its own git repo with an auto-commit on each save: free undo, diff and history. Push/pull to a remote when you want.
- Run playbooks (full or by tags) with live per-task output, a structured pass/fail summary, and a global, filterable run history.
- Auto-detects playbooks/inventories/roles; works with both the scaffolded layout
and flat repos (playbook +
hosts.iniat the root). - Cron scheduler in-process (APScheduler — no extra worker), run templates, environments, ad-hoc commands.
Secrets
- Credentials (SSH keys, SSH passwords, vault/become passwords, WireGuard) encrypted at rest with Fernet.
- Ansible Vault for in-repo secrets — encrypt/decrypt/view from the UI.
Editor & dependencies
- Monaco editor with inline ansible-lint, per-file commit history (View / Restore past versions), structured inventory editing (INI + YAML).
- Playbook builder (simple → advanced: handlers, loops,
serial,becomeper task). - Ansible Galaxy: install/remove roles & collections by name or from
requirements.yml.
Operations
- Credential test — probe an SSH key, SSH password, or sudo password against an inventory before running a 30-task playbook; per-host ✓/✗ result.
- Ad-hoc command builder — any module + args + host pattern in one form (not just "ping all").
--limitquick-pick — click groups/hosts from the inventory to build the Ansible:-separated limit string.- Run artifacts — files a run wrote into the repo are committed automatically; the run-detail page previews them inline or opens them in the editor.
- Per-schedule timezone — cron expressions interpret in any IANA timezone
(
Europe/Warsaw,America/New_York, …); next-fire times honour DST.
✨ AI assistant (the part that's actually unique)
- Chat on every page (slide-out dock) and a full page — one shared conversation, live-synced and remembered across refreshes. Replies stream in token-by-token.
- Agent mode: a tool-using agent that actually works on a project — it inspects
files, writes/edits/moves them, installs collections, and can dry-run (
--check) or run a playbook, then read the failures and fix them. It uses the self-checking layers below (it won't finish on a hallucinated module), every change is a separate git commit you can revert, and each capability is opt-in (read-only → "allow changes" → "allow delete / web"). - NL → playbook: describe what you want, get a reviewable spec + YAML.
- Remediation loop: after a failure, get a concrete fix and re-run only the failed hosts — or one-click Fix with agent to have the agent read the run, patch the playbook, and preview it.
- Pre-run preview: a
--checkdry-run narrated in plain language ("what will change, where"). - Auto-runbook: living Markdown docs generated from your playbooks.
- Self-checking layers behind all of it:
- Anti-hallucination — module names validated against
ansible-doc(ansible.builtin.ufw→ flagged as fake;community.general.ufw→ "install the collection", not "doesn't exist"). - Rule engine (neuro-symbolic) — catches lockout, destructive ops, contradictions, handler misuse, malformed structure, even inside roles.
- RAG / BM25 — grounds answers in the modules actually installed and in your project's real file contents. Works offline; optional web-fetch from docs.ansible.com when you allow it.
- Anti-hallucination — module names validated against
Pluggable model backends: Anthropic, OpenAI (or any OpenAI-compatible endpoint), or a local Ollama server.
git clone https://github.com/mar0ls/playforge.git
cd playforge
cp .env.example .env # optional — for a password, AI keys, etc.
docker compose up -d # → http://127.0.0.1:8765This pulls the published image; nothing is built locally. docker-compose.yml
also runs on its own if you'd rather not clone — download that one file and run
docker compose up -d next to it.
curl -s localhost:8765/health reports the running version and schema version.
Pin a version with PLAYFORGE_VERSION=1.0.1 in .env; latest only ever moves
to a stable release.
The .env step is optional: with no .env the app runs single-user/local with no
AI. Configure the AI helper under Settings → AI helper at runtime, or set
OLLAMA_URL / ANTHROPIC_API_KEY / OPENAI_API_KEY in .env. State (projects,
git repos, SQLite) lives under ./data on the host and survives rebuilds.
Three modes, decided by what exists — no flag to set:
| Accounts | ANSIBLE_GUI_PASSWORD |
Behaviour |
|---|---|---|
| yes | — | Sign in with a username; the role decides what you can do |
| no | set | One shared password, full access |
| no | unset | No login at all (single-user local) |
Roles are admin, operator and viewer: viewer reads, operator also runs playbooks and edits files, admin also manages credentials, users and settings. Every API route declares the capability it needs, and an unmapped route denies everyone.
Creating the first administrator, either way:
# automated deploys — set in .env or your orchestrator
ANSIBLE_GUI_ADMIN_USER=admin
ANSIBLE_GUI_ADMIN_PASSWORD=... # or ANSIBLE_GUI_ADMIN_PASSWORD_FILE=/run/secrets/pw
# or, with neither set, open /setup and paste the token from the log
docker compose logs app | grep "SETUP TOKEN"The setup page needs that token because the port is published on 0.0.0.0:
without it, anyone who reached the port between docker compose up and your
browser could claim the instance.
Failed logins are throttled per client address — 5 attempts, then a lockout doubling from 30s to 15 minutes.
State-changing requests are checked for cross-site origin, and a browser must
also send a CSRF token. A client that never loads a page — curl, a script,
make lab-regression — sends no Origin and picks up no cookie, so it needs no
token and is unaffected. SECURITY.md explains why that is the
coherent rule rather than a way around the check.
- Run isolation:
ANSIBLE_GUI_RUN_ISOLATION=1runs playbooks in a sandbox that can't see/data, so a playbook can't read the credential vault or other projects. Needs twosecurity_optlines indocker-compose.yml— see SECURITY.md for the trade-off. Off by default. - Import your projects: bind-mount their directories read-only (see the
commented
/import/*examples indocker-compose.yml). - Naming note: the image and container are
playforge; environment variables keep theANSIBLE_GUI_*prefix for backward compatibility.
State lives in ./data: the SQLite DB, one git repo per project, and master.key
— the key the credential vault is encrypted with. Lose master.key and the
stored credentials are gone.
scripts/backup.sh # → ./backups/playforge-backup-<version>-<stamp>.tar.gz
docker compose up --build -d
curl -s localhost:8765/health # version + schema_versionBackups are safe to take while the app runs — the DB goes through SQLite's online
backup API, so it can't catch a half-written transaction. The archive is 0600
and contains the master key and your project repos; encrypt it before it leaves
the host:
gpg --symmetric --cipher-algo AES256 backups/playforge-backup-*.tar.gzSchema migrations run on start and are recorded in the DB, so upgrading across several versions at once is fine. Downgrading isn't supported — an older build against a newer database logs a warning rather than half-working. To go back:
docker compose down
scripts/restore.sh backups/playforge-backup-1.0.0-<stamp>.tar.gzrestore.sh won't overwrite a populated ./data without --force, and moves the
existing directory aside rather than deleting it.
See it end to end without a remote host:
scripts/demo.sh # creates a project + a deliberately failing run on localhostIt prints a run URL — open it and click Fix with agent: the agent reads the failure, fixes the playbook (a typo'd module), and previews it. Then Re-run to confirm. (To record a GIF, screen-capture the browser during that step.)
make build # build playforge:dev from source (via the dev override)
make test # run the full suite inside the image (git + ansible available)
make up / down # start / stop
make backup # snapshot ./data before you break somethingFor live code reload while developing, layer the dev override (adds the build
context, bind-mounts backend/app, runs uvicorn with --reload):
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --buildVersion lives in exactly one place: __version__ in backend/app/__init__.py.
- Bump it and add the matching
## [x.y.z]section toCHANGELOG.md. git tag vX.Y.Z && git push origin vX.Y.Z.
If the tag, __version__ and CHANGELOG.md don't agree, the release fails before
anything is pushed to Docker Hub. (That check lives in the workflow, not in CI —
CI runs the suite inside the image, where CHANGELOG.md isn't present.)
The Release workflow verifies the tag agrees with the code, builds and pushes
amd64 + arm64 to Docker Hub, pulls the published image back to check /health
answers with the version it just tagged, and then opens the GitHub Release. It
needs one repository secret: DOCKERHUB_TOKEN, a Docker Hub access token with
Read/Write (not the account password). The Docker Hub username sits in the
workflow's env — it's already public in the image name, so making it a secret
would protect nothing.
The container exposes GET /health (DB ping included). The base compose wires
a Docker healthcheck against it, so docker ps shows (healthy) once the
service is up.
Run a full API-level regression for a dockerized VM lab (preflight + multiple playbook runs + JSON report suitable for CI):
PROJECT_ID=<your_project_id> make lab-regressionOptional knobs:
PROJECT_ID=<id> \
BASE_URL=http://127.0.0.1:8765 \
INVENTORY_PATH=inventories/lab.ini \
HOST_PATTERN=all \
CHECK_PREFLIGHT=true \
INCLUDE_TARGETS_PREFLIGHT=true \
REQUEST_TIMEOUT_SEC=600 \
PLAYBOOKS_CSV=playbooks/lab_ping.yml,playbooks/lab_file.yml,playbooks/lab_apt.yml \
EXTRA_VARS_JSON='{"some_var":"value"}' \
make lab-regressionThe command exits non-zero when preflight fails or any run is not ok/
successful.
The test suite (840+ cases) runs in CI on every push and PR, with a coverage
floor CI enforces (make coverage runs the same gate locally) — see
.github/workflows/ci.yml.
- Air-gap: no runtime network calls. All UI JS (including the Monaco editor) is
served from the image, popular Galaxy collections are baked in for
ansible-doc, and AI can run against a local Ollama. Online docs lookup is off by default and is the only thing that reaches the internet when you turn it on. - Reproducible builds: Python deps install from
backend/requirements-lock.txtwith--require-hashes; Monaco is fetched at build time against a pinned SHA-256. The same git tag produces the same image. - No heavy infra: SQLite (WAL mode), in-process scheduler, direct runner. One container.
- Third-party components are listed in THIRD_PARTY_LICENSES.md.
The HTTP API under /api is what the UI itself uses, and it is stable: paths and
response fields do not change or disappear within a major version. New optional
fields and new endpoints can arrive at any time, so parse responses tolerantly.
This is enforced, not just stated. backend/tests/api_contract.json records every
operation with its required parameters and body fields, and the suite fails if one
disappears or grows a new requirement. Response field names are held by the API
tests that read them rather than by the snapshot — handlers return plain dicts, so
OpenAPI has no response schema to pin.
There is deliberately no /api/v1 prefix. The API is consumed by this app's own
frontend and by scripts people write against their own instance, not published as
a separate product; a version segment would be ceremony without an audience. If a
genuinely breaking change ever becomes necessary, it arrives as /api/v2
alongside the existing paths, and /api keeps working for at least one minor
release after it is announced in the CHANGELOG.
GET /openapi.json and /docs describe the current surface.
Copyright (C) 2026 mar0ls. Playforge is licensed under the GNU General Public
License v3.0 — see LICENSE. GPL-3.0 keeps the project compatible with
its core dependencies (ansible-core, ansible-runner, ansible-lint), which are
GPL-3.0 themselves. Forks and redistributed versions must stay open-source under
the same license.

