01 - Deploy a Server
In this tutorial you’ll stand up a cloacina-server from scratch,
bootstrap an admin API key, create a tenant, upload a packaged
workflow, run an execution, and verify it via the metrics and health
endpoints. By the end you’ll have the operator’s mental model of how
the pieces fit together.
- How to start
cloacina-serveragainst a fresh PostgreSQL or SQLite backend. - How the bootstrap key is generated and where to find it (and why you only get to capture it once).
- How to provision a tenant and a tenant-scoped API key.
- How to upload a
.cloacinapackage and trigger an execution. - How to confirm the execution via the HTTP API, Prometheus metrics, and structured logs.
cloacinactlandcloacina-serverbinaries on yourPATH(or built locally and accessible viacargo run).- PostgreSQL 14+ accessible from the server, or willingness to use SQLite for this tutorial. Multi-tenant production deployments require Postgres; for a single-tenant first-run, SQLite is fine.
- A pre-built
.cloacinapackage — the example below usesexamples/features/workflows/packaged-workflow/from the Cloacina repository. See Use cloacina-compiler Locally if you need to build one. curlfor ad-hoc HTTP calls. Optional:jqfor prettier JSON responses.
15–25 minutes (most of which is waiting for the first package build).
createdb cloacina
psql -d cloacina -c "CREATE USER cloacina WITH PASSWORD 'changeme';"
psql -d cloacina -c "GRANT ALL PRIVILEGES ON DATABASE cloacina TO cloacina;"
export DATABASE_URL='postgres://cloacina:changeme@localhost/cloacina'
No setup needed; the server will create the file on first start.
export DATABASE_URL='sqlite:///tmp/cloacina-tutorial.db'
Pick one. The rest of the tutorial uses $DATABASE_URL.
cloacinactl server start --bind 127.0.0.1:8080 --database-url "$DATABASE_URL"
You’ll see output like:
Starting API server
Bind: 127.0.0.1:8080
Database: postgres://cloacina:***@localhost/cloacina
Home: /home/you/.cloacina
WARNING: Server running without TLS
API server is running on http://127.0.0.1:8080
GET /health — liveness check
GET /ready — readiness check
GET /metrics — Prometheus metrics
...
The server runs in the foreground for this tutorial. Open a second terminal for the next steps.
On first startup with no API keys in the database, the server
generated an admin key and wrote it to ~/.cloacina/bootstrap-key
with mode 0600. This is the only time the plaintext is
surfaced.
ADMIN_KEY=$(cat ~/.cloacina/bootstrap-key)
echo "Admin key captured: ${ADMIN_KEY:0:8}..."
Production note: in a real deployment, capture this key into your secret manager immediately and either delete the file or ensure it’s only readable by the operator. The bootstrap path is skipped on subsequent starts; if you lose the key, recovery requires direct database access.
Confirm the server is up:
curl -s http://127.0.0.1:8080/health | jq .
# {"status": "ok"}
curl -s http://127.0.0.1:8080/ready | jq .
# {"status": "ready"}
Tenants are isolated PostgreSQL schemas. For SQLite, the “tenant” is a logical name on the same database.
cloacinactl tenant create acme \
--server http://127.0.0.1:8080 \
--api-key "$ADMIN_KEY"
Response (the password is not returned per security policy):
{"schema_name": "acme", "username": "acme_user"}
List tenants to confirm:
cloacinactl tenant list \
--server http://127.0.0.1:8080 \
--api-key "$ADMIN_KEY"
# acme
Tenant-scoped keys are how application clients authenticate. They can’t escalate to other tenants.
ACME_KEY=$(cloacinactl key create acme-tutorial \
--role write \
--tenant acme \
--server http://127.0.0.1:8080 \
--api-key "$ADMIN_KEY" \
--output id)
echo "Acme key captured: ${ACME_KEY:0:8}..."
The
key createresponse shows the plaintext exactly once. Capture it now or recreate it later. The server returns metadata (ID, name, role) on subsequentkey listcalls, but never the plaintext.
Save the credentials in ~/.cloacina/config.toml so subsequent
commands don’t need every flag:
cloacinactl config profile set acme-prod \
--server http://127.0.0.1:8080 \
--api-key "$ACME_KEY"
cloacinactl config profile use acme-prod
Now cloacinactl reads the server URL and key from the profile by
default. The key is stored as a literal string; for production use
the env:VAR or file:PATH schemes documented in API Key
Schemes.
Build the example packaged workflow if you haven’t already:
# From the Cloacina repo root
cd examples/features/workflows/packaged-workflow
cloacinactl package build .
cloacinactl package pack .
# Produces packaged-workflow-<version>.cloacina
Upload it:
cloacinactl package upload packaged-workflow-*.cloacina --tenant acme
Response:
{"package_id": "f47ac10b-...", "tenant_id": "acme"}
The reconciler runs through its six-step pipeline to load the package. Watch the server logs in your first terminal. You’ll see lines like:
INFO loading package package_id=f47ac10b-...
DEBUG step_load_cron_triggers: 0 cron schedules
DEBUG step_load_custom_triggers: 0 triggers
DEBUG step_load_reactors: 0 reactors
DEBUG step_load_triggerless_cgs: 0 trigger-less graphs
DEBUG step_load_reactor_bound_cgs: 0 graphs
DEBUG step_load_workflows: 1 workflow registered (my_workflow)
INFO package loaded successfully
The 0 counts reflect what your specific example package declares;
a more elaborate package would show non-zero counts at each step.
The package loaded successfully line is the signal that all six
steps completed.
Verify the workflow is visible (allow a couple of seconds for the reconciler to run if you’re polling immediately after upload):
cloacinactl workflow list --tenant acme
# my_workflow v0.1.0 (description, task count)
cloacinactl workflow run my_workflow --tenant acme --context '{"input": "hello"}'
# 7d8e9f0a-1b2c-3d4e-5f60-718293a4b5c6 (the execution ID)
Capture the execution ID and poll its status:
EXEC_ID="7d8e9f0a-1b2c-3d4e-5f60-718293a4b5c6"
cloacinactl execution status "$EXEC_ID" --tenant acme
# Status: Running
# ...
# After a few seconds:
cloacinactl execution status "$EXEC_ID" --tenant acme
# Status: Completed
Inspect the per-task event log:
cloacinactl execution events "$EXEC_ID" --tenant acme
# task_started, task_completed, ... per task
The server emits Prometheus metrics on every workflow and task event. Snapshot them:
curl -s http://127.0.0.1:8080/metrics | grep -E '^cloacina_(workflows|tasks)_total'
Expected output (numbers vary):
cloacina_workflows_total{status="completed",reason="ok"} 1
cloacina_tasks_total{status="completed",reason="ok"} 3
The 1 counts your one execution; 3 counts the three tasks in
the example workflow.
Tail the JSON log:
tail -n 20 ~/.cloacina/logs/cloacina-server.log | jq .
Look for lines with your request_id (set in the
x-request-id response header on every request) and the workflow’s
execution_id. This is how you correlate a specific API call to
the server-side handling, including any internal failures.
For more on observability, see Observe Execution State.
For this tutorial, stop the server with Ctrl-C in the first terminal. To clean up:
# Drop the tenant (Postgres path)
cloacinactl tenant delete acme --force \
--server http://127.0.0.1:8080 \
--api-key "$ADMIN_KEY"
# Drop the package (alternative: just stop the server; the package
# stays in the registry for the next startup)
cloacinactl package delete <package_id> --tenant acme
# Remove SQLite file (SQLite path)
rm /tmp/cloacina-tutorial.db
# Forget the profile
cloacinactl config profile delete acme-prod
You now have:
- A running
cloacina-serveragainst either Postgres or SQLite. - A bootstrap admin key (which you’ve captured into your shell or secret manager).
- A tenant
acmewith its own scoped API key. - A
cloacinactlprofile that uses the tenant key by default. - A loaded packaged workflow that has executed at least once.
- Visibility via the HTTP API, Prometheus metrics, and structured logs.
- Configure a Multi-Tenant Deployment — productionize multi-tenancy and learn the operational caveats (the runner-schema execution gap is critical for true isolation).
- Production Deployment — add TLS termination, reverse proxy, and external secret management.
- Observe Execution State — wire metrics into Prometheus + OpenTelemetry tracing.
- Reconciler Pipeline — understand what just happened during the package upload.