English · Русский
A manifest-driven setup wizard and generic update agent for Docker Compose stacks.
Ship any dockerized application with a guided first-run installer and safe,
in-app updates — by writing a single setup-manifest.yaml. The engine and agent
contain no knowledge of your application: everything app-specific lives in the
manifest (data), never in code.
- vs. hand-rolled
docker compose— your operators get a browser wizard with validation, secret handling and registry login instead of editing.envby hand. - vs. Helm / Ansible — no cluster and no playbooks. Just Compose plus one YAML manifest; the operator clicks through a wizard in the browser.
- vs. a PaaS (Coolify / CapRover) — this is not a platform. It's an embeddable installer/updater you ship with your app so customers can self-host it.
- Generic by design — the same image installs and updates any Compose stack. All steps, fields, validators, update phases and modules are described in the manifest.
| Service | Port | Role |
|---|---|---|
| Engine (wizard) | 8000 | Public-facing: serves the Vue SPA + /api/setup/*, reads the manifest, renders/validates steps |
| Agent | 8001 | Internal: /agent/* — Docker operations (pull/up/down), .env, registry login, backups, updates, modules |
Both run in one image (docker-entrypoint.sh starts them). In a Compose deployment
the agent can run as its own service on a private network — set AGENT_BASE_URL.
stack-installer never builds images — it pulls ready-made images that you have
already built and pushed to a container registry (usually a private one). Your
docker-compose.yml references them by name and tag; the agent pulls them on first
install and on every update.
The chain:
- You publish images. Your CI builds and pushes your service images to your
registry, tagged with a release version. Compose references them as, e.g.,
image: ${REGISTRY_HOST}/you/myapp-backend:${VERSION}. - The operator authenticates in the wizard.
stack.registryin the manifest declares how to log in (none/basic/license/token). After the relevant step passes, the agent runsdocker login <REGISTRY_HOST>. - The agent pulls.
prefetchrunsdocker compose pullin the background while the operator finishes the wizard; thepullphase re-pulls on updates. Docker fetches each service's image by tag from the registry, using the login from step 2. - Updates fetch the new tags. On update the agent pins the target
VERSION, pulls the new image tags, and — if configured — downloads the signeddocker-compose.ymlandsetup-manifest.yamlfor that version from the license server (see Security model) before bringing the stack up.
A common pattern for commercial self-hosted apps: instead of giving customers raw
registry credentials, you put a registry proxy in front of your registry that
accepts the customer's license key as the Docker password. The wizard collects the
license key once; the agent runs
docker login <REGISTRY_HOST> -u license -p <LICENSE_KEY>, Docker performs the standard
/v2/token exchange, and the proxy decides which images (and which versions/tiers) that
license may pull. The customer never sees real registry credentials, and you can revoke
or scope access per license. (See the license row under
Registry authentication.)
- Docker Engine with Docker Compose v2 (the agent shells out to
docker compose). - The host Docker socket mounted into the agent (
/var/run/docker.sock). - For local development: Python 3.13, Node 20 (frontend).
PyNaClis optional and only needed for manifest-signature verification on the update path.
- Write a
setup-manifest.yamldescribing your wizard (see below andexamples/). - Run the image, mounting the Docker socket and your project directory:
docker run \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /path/to/project:/host-project \
-e HOST_PROJECT_DIR=/host-project \
-e COMPOSE_FILE_PATH=/host-project/docker-compose.yml \
-e COMPOSE_ENV_PATH=/host-project/.env \
-e COMPOSE_PROJECT_NAME=myapp \
-e MANIFEST_PATH=/host-project/setup-manifest.yaml \
-p 8000:8000 \
<your-registry>/stack-installer:latestThe published image name depends on your registry. Build it yourself from this repo's
Dockerfile(docker build -t stack-installer .) or pull from wherever you publish it.
- Open
http://<host>:8000, complete the wizard. On the final step the engine writes the collected values to.env, runsdocker compose up -d, and calls yourPOST /api/setup/applyhook. Seeexamples/for a runnable two-step example.
manifest_version: 1
stack:
name: "My App"
compose_file: docker-compose.yml
project_name: myapp
steps:
- id: config
title: "Configuration"
fields:
- key: APP_URL
label: "Public URL"
type: url
required: true
env: true
finalize:
apply_env: true
up: true| type | Notes |
|---|---|
string |
Plain text input |
password |
Masked input; set secret: true to protect in .env |
number |
Numeric, optional min/max |
email |
Validated email format |
url |
Validated URL format |
select |
Dropdown; provide options: [a, b, c] or options_from: agent.stack/profiles |
totp_setup |
Generates a TOTP secret + QR code; see builtin.totp_setup |
| flag | Default | Effect |
|---|---|---|
env: true |
false | Value is written to .env on finalize |
secret: true |
false | Marks value as secret (chmod 600 in .env, cleared from state after finalize) |
required |
false | User must fill this field |
required_when: {OTHER_KEY: value} |
— | Conditionally required |
hide_when: {OTHER_KEY: value} |
— | Hide field when condition matches |
show_when: {OTHER_KEY: value} |
— | Show field only when condition matches (inverse of hide_when) |
Values in required_when, hide_when, and show_when may be plain scalars (string equality) or a
{contains: <val>} dict to check membership in a multiselect field, e.g.:
show_when: { extra_services: { contains: tools } }Attach a validate: block to run a check after the user fills a step:
validate:
kind: builtin.http_check
endpoint: "https://api.example.com/ping"
expect:
status: 200
json_path: ok
equals: trueAvailable built-in validators:
| kind | What it checks |
|---|---|
builtin.http_check |
HTTP endpoint — status code + optional JSON path |
builtin.db_connect |
MySQL or PostgreSQL TCP connect + auth |
builtin.redis_ping |
Redis PING |
builtin.s3_head_bucket |
S3-compatible bucket access (HeadBucket) |
builtin.totp_setup |
Generate TOTP secret + QR code |
webhook |
Custom self-contained endpoint (see CONTRACT.md) |
Add validate_post_up: true to defer a check until after docker compose up:
validate:
kind: builtin.http_check
endpoint: "{APP_URL}/api/health"
validate_post_up: trueUse group: with a toggle: to show/hide a set of fields:
fields:
- group: database
toggle:
key: db_local
label: "Use built-in database"
default: true
when_off:
- key: DB_HOST
type: string
required: true
env: true
- key: DB_PASSWORD
type: password
env: true
secret: true
validate_when_off:
kind: builtin.db_connect
driver: mysqlImages are pulled in the background while the user fills later steps:
prefetch:
trigger_after_step: auth # pull starts after this step passes
services: all # or list of service namesfinalize:
apply_env: true # write all env:true fields to .env
up: true # run docker compose up -d
post_up_hook:
endpoint: "internal://backend/api/setup/apply"
method: POST
payload: collected # send all collected wizard values
timeout: 120
post_up_checks:
- kind: builtin.http_check
endpoint: "internal://backend/api/health"
retries: 20See CONTRACT.md for the full post-up hook contract.
stack:
registry:
host: registry.example.com
login:
kind: basic
username: deploy
password_from_env: REGISTRY_PASSWORDSupported kind values:
| kind | How it works |
|---|---|
none |
Public registry — no login |
basic |
docker login with username + password from password_from_env (collected field or env var) |
license |
The collected key_field value is used as the registry password. Equivalent to docker login <host> -u license -p <LICENSE_KEY>. Docker performs the /v2/token exchange itself against the registry proxy. Typical use: a private registry proxy that accepts a license key as the password. |
token |
(optional/legacy) Fetches a pre-obtained pull token from credential_endpoint via GET, then uses it as the password. |
After first install the agent can update the running stack to a new release from the
app's own admin UI, without SSH. Updates are declarative: you list ordered phases
under runtime.apply in the manifest, and the agent runs them. The agent itself stays
generic — the phases are data.
runtime:
apply:
phases:
- { id: preflight, kind: builtin.preflight, min_disk_gb: 2 }
- { id: backup, kind: builtin.db_backup }
- { id: fetch-compose, kind: builtin.fetch_compose } # pull signed compose/manifest for the target version
- { id: pull, kind: builtin.pull }
- { id: maintenance-on, kind: builtin.maintenance, mode: "on" }
- { id: up, kind: builtin.up }
- { id: migrate, kind: exec, service: app, command: ["app", "migrate"] }
- { id: health-check, kind: builtin.http_check, endpoint: "internal://nginx/healthz", retries: 15 }
- { id: maintenance-off, kind: builtin.maintenance, mode: "off" }
- { id: report, kind: builtin.hook, endpoint: "{LICENSE_SERVER_URL}/api/v1/installed", method: POST }Key properties:
- Two-step deploy. The update can pause after the non-destructive part (backup + image pull) and wait for the operator to confirm before the destructive part (maintenance → recreate → migrate). The site stays up during preparation.
- Progress that survives restarts. Phase progress streams over SSE; the agent also
serves a standalone progress page (
/agent/ui,/agent/progress/{id}) so the operator keeps seeing progress even while the app's own web tier is being recreated. - Maintenance mode. A flag on a shared volume; your reverse proxy returns
503(a static maintenance page) while it is set, then resumes when the new version is healthy. - Forward-only on failure. The agent does not auto-rollback — premature or partial rollbacks cause more harm than good. On failure it leaves the stack in a consistent state and surfaces the error; the operator decides to retry (fix-forward) or trigger a manual rollback.
- Resume after crash. Completed phases are recorded; a re-run skips them.
The agent can install / update / uninstall optional add-ons at runtime — extra services, or extra application code — driven by a module manifest (data, same philosophy as the setup manifest). A module install can: extract code from a module image into a named volume, write env vars, bring up sidecar containers, enable reverse-proxy routes, create storage buckets, and run migrations. Update mode does a clean re-extract of the new version and force-recreates only that module's containers; it is forward-only and never touches user data on failure. Uninstall stops the module's containers and removes its code, but never deletes data or buckets.
This tool performs privileged operations. Understand the model before exposing it.
- The agent is privileged. It uses the host Docker socket (
/var/run/docker.sock), which is effectively root on the host. Treat the agent as a trusted, internal-only component. - Do not expose the agent control API.
/agent/apply,/agent/up,/agent/env,/agent/install_module, … are unauthenticated and internal (the application backend calls them over a private network). Your reverse proxy must expose only the two read-only progress endpoints needed by the standalone progress page (/agent/uiand/agent/progress/) — nothing else under/agent/. - Hook authentication. The post-up / report hooks use a shared secret
(
SETUP_APPLY_SECRET, sent asX-Setup-Secret), resolved at runtime; your application verifies it before acting on the hook. - Signed updates. Release descriptors are ed25519-signed. Before downloading or
applying a release's compose/manifest the agent verifies the signature against
MANIFEST_PUBKEY_HEX. If no public key is configured, verification is skipped — that is a development-only mode; always set the public key in production, otherwise a forged manifest could point the agent at an attacker's image. - Restricted per-client overrides. When per-deployment service overrides are used the
agent renders them under a strict policy: images only from the configured registry host,
named volumes only, no
privileged/ host bind-mounts / host network. - Secrets handling. Fields marked
secret: trueare written to.envwithchmod 600and cleared from wizard state after finalize. - No app knowledge, no telemetry. The engine and agent are generic. They talk only to your own endpoints and, if configured, your registry / license server.
Browser
└─ Vue 3 SPA (port 8000, served by engine)
└─ GET/POST /api/setup/*
└─ engine (FastAPI, port 8000)
├─ reads setup-manifest.yaml (engine/manifest.py)
├─ renders step schemas (engine/steps.py)
├─ validates fields (engine/validators/)
├─ tracks wizard state (engine/state.py)
└─ POST /agent/* (HTTP, AGENT_BASE_URL)
└─ agent (FastAPI, port 8001)
├─ docker compose pull / up / down (agent/compose_runner.py)
├─ writes .env (agent/env_writer.py)
├─ registry auth (agent/registry_auth.py)
├─ module install / uninstall (agent/module_installer.py)
├─ apply phases (update flow) (agent/apply_runner.py)
└─ backup drivers (mysql/postgres) (agent/backup/)
pip install -r requirements.txt
export MANIFEST_PATH=/path/to/setup-manifest.yaml
export AGENT_BASE_URL=http://localhost:8001
export HOST_PROJECT_DIR=/path/to/your/project
uvicorn engine.api:app --reload --port 8000export HOST_PROJECT_DIR=/path/to/your/project
export COMPOSE_FILE_PATH=$HOST_PROJECT_DIR/docker-compose.yml
export COMPOSE_ENV_PATH=$HOST_PROJECT_DIR/.env
export COMPOSE_PROJECT_NAME=myapp
export AGENT_DB_PATH=/tmp/agent-state.sqlite
export AGENT_LOCK_PATH=/tmp/agent-lock
export MANIFEST_PATH=/path/to/setup-manifest.yaml
uvicorn agent.api:app --reload --port 8001cd frontend
npm install
npm run dev # proxies /api/* to the engine on port 8000
npm run build # production build → frontend/dist/ (served by the engine)python -m pytest -q- Create
engine/validators/builtin_<name>.pywith avalidate(config, values) -> dictfunction. - Register the
kindstring in therun_step_validatedispatch inengine/steps.py. - Document the new
kindin the validator table above.
- Add the type string to
ManifestFieldinengine/manifest.py. - Handle rendering in
engine/steps.py(_render_field) if extra schema data is needed. - Add a matching input component branch in
frontend/src/FieldRenderer.vue.
- Create
agent/backup/<engine>.pysubclassingBackupDriverBasefromagent/backup/base.py. - Register the engine name in
agent/backup/registry.py. - Add tests in
tests/test_backup_drivers.py.
CONTRACT.md— the post-up hook and webhook-validator contracts your app implements.CONTRIBUTING.md— development setup and extension points.LICENSE— MIT.