Deployment Guide: Docker, Railway, HTTP API, and GraphQL
This guide covers running Animus in a container, deploying it to Railway, and using the HTTP and GraphQL transport APIs that the animus web surface exposes via installed plugins.
1. Containerizing Animus
Multi-stage Dockerfile
The recommended pattern downloads a prebuilt animus release binary (no in-image Rust compilation) and bakes required plugins into the image at build time so the container boots without network access.
# ---- stage 1: fetch the prebuilt animus binary ----
FROM debian:bookworm-slim AS animus-fetch
ARG ANIMUS_VERSION=v0.7.0-rc.55
ARG ANIMUS_TARGET=x86_64-unknown-linux-gnu
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN curl -fsSL -o /tmp/ao.tar.gz \
"https://github.com/launchapp-dev/animus-cli/releases/download/${ANIMUS_VERSION}/ao-${ANIMUS_VERSION}-${ANIMUS_TARGET}.tar.gz" \
&& tar xzf /tmp/ao.tar.gz -C /tmp \
&& cp "/tmp/ao-${ANIMUS_VERSION}-${ANIMUS_TARGET}/animus" /usr/local/bin/animus \
&& chmod +x /usr/local/bin/animus
# ---- stage 2: runtime ----
# Use a glibc-new enough base. animus binaries built on Ubuntu 24.04 need
# GLIBC >= 2.39; debian:trixie ships 2.41.
FROM debian:trixie-slim
ENV DEBIAN_FRONTEND=noninteractive
# libdbus-1-3 + libssl3: animus links them for the secret backend and TLS.
# git + curl: plugin install uses them at build time.
# nodejs + npm: required if using the claude provider CLI.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates git curl libdbus-1-3 libssl3 \
&& rm -rf /var/lib/apt/lists/*
# The claude provider plugin shells out to the `claude` CLI. Install it if you
# use animus-provider-claude. Omit this block for other providers.
RUN apt-get update && apt-get install -y --no-install-recommends nodejs npm \
&& npm install -g @anthropic-ai/claude-code && npm cache clean --force \
&& rm -rf /var/lib/apt/lists/*
# Run as non-root. The claude CLI refuses --dangerously-skip-permissions as root,
# which the claude provider uses for autonomous agent runs.
RUN useradd -m -u 10001 -s /bin/bash appuser
WORKDIR /app
COPY --from=animus-fetch /usr/local/bin/animus /usr/local/bin/animus
COPY .animus /app/.animus
RUN chown -R appuser:appuser /app
USER appuser
ENV HOME=/home/appuser
# Bake all required plugins into the image (no runtime network needed).
# --signature-policy=disabled: use this only when pulling from a registry
# whose cosign identity is not yet in animus's trusted-publisher list.
# Remove this flag once your plugin repos are listed in trusted-signers.yaml.
ARG SIG=--signature-policy=disabled
RUN animus plugin install launchapp-dev/animus-provider-claude $SIG --allow-shadow-builtin --project-root /app \
&& animus plugin install launchapp-dev/animus-subject-default $SIG --project-root /app \
&& animus plugin install launchapp-dev/animus-queue-default $SIG --project-root /app \
&& animus plugin install launchapp-dev/animus-workflow-runner-default $SIG --project-root /app \
&& animus plugin install animus-ecosystem/animus-transport-http $SIG --project-root /app
# Confirm all required roles are satisfied before shipping the image.
RUN animus daemon preflight --project-root /app
COPY deploy/start.sh deploy/webapi-forward.js /app/deploy/
RUN chmod +x /app/deploy/start.sh
EXPOSE 8088
CMD ["/app/deploy/start.sh"]Key build arguments:
| ARG | Default | Description |
|---|---|---|
ANIMUS_VERSION | v0.7.0-rc.55 | Release tag to download from launchapp-dev/animus-cli |
ANIMUS_TARGET | x86_64-unknown-linux-gnu | Target triple; must match the container's glibc |
SIG | --signature-policy=disabled | Signature policy flag passed to every plugin install |
Plugin preflight and required roles
The daemon refuses to start when any of these roles is unsatisfied:
| Role | Default plugin |
|---|---|
| At least one provider | launchapp-dev/animus-provider-claude (or another) |
At least one subject_backend (any kind, since v0.5.20) | launchapp-dev/animus-subject-default |
workflow_runner | launchapp-dev/animus-workflow-runner-default |
queue | launchapp-dev/animus-queue-default |
animus daemon preflight in the RUN layer above catches any missing role at build time, not at runtime.
Two escape hatches are available at daemon startup (not substitutes for a working plugin set in production):
--auto-install— install any missing required-role plugins from their pinned defaults before the daemon starts. Causes a runtime network fetch.--skip-preflight— bypass the check entirely. The daemon will fail at the first plugin RPC if the plugin is really missing.
State and persistence
Scoped runtime state lives under ~/.animus/<repo-scope>/ inside the container (where ~ resolves to the running user's $HOME). This includes runs, artifacts, the queue database, secrets, and compiled workflow config.
Project-local config lives at .animus/ within the project directory (/app/.animus in the skeleton above).
Ephemeral (default): state is lost on container restart. Acceptable for stateless pipelines where subjects are owned by an external store and the daemon is purely a workflow executor.
Persistent (recommended for production): mount a volume over /home/appuser/.animus so state survives redeploys.
VOLUME ["/home/appuser/.animus"]Or in docker run:
docker run -v animus-state:/home/appuser/.animus my-animus-image2. Running the Daemon in a Container
Use animus daemon run — not daemon start — in containerized environments. daemon run is the foreground verb and writes to stdout/stderr, which container runtimes collect as logs. daemon start forks a background process and exits, which makes the container exit immediately.
A minimal entrypoint (deploy/start.sh):
#!/usr/bin/env bash
set -euo pipefail
ROOT=/app
# Start the daemon in the foreground (correct for containers).
# Preflight runs automatically; --auto-install installs missing required plugins
# from pinned defaults if any were omitted from the image.
animus daemon run --project-root "$ROOT" &
DAEMON_PID=$!
# Wait for the control socket to become healthy before spawning the transport.
for _ in $(seq 1 30); do
animus daemon health --project-root "$ROOT" >/dev/null 2>&1 && break
sleep 1
done
# Spawn the installed transport plugin. It binds 127.0.0.1:8080 (loopback).
animus web serve --project-root "$ROOT" &
WEB_PID=$!
# Publish the API to other containers through a forwarder that checks
# ANIMUS_WEBAPI_TOKEN (below). Listens on 0.0.0.0:${WEBAPI_PORT:-8088}.
node /app/deploy/webapi-forward.js &
FWD_PID=$!
trap 'kill "$DAEMON_PID" "$WEB_PID" "$FWD_PID" 2>/dev/null || true' EXIT
wait "$DAEMON_PID"The transport always binds 127.0.0.1:8080 under animus web serve, so other containers can't reach it directly. A small forwarder in the same container publishes it: it listens on the container's interface, refuses requests without Authorization: Bearer $ANIMUS_WEBAPI_TOKEN, and forwards the rest to the transport (deploy/webapi-forward.js):
const http = require("http");
const SECRET = process.env.ANIMUS_WEBAPI_TOKEN || "";
const PORT = parseInt(process.env.WEBAPI_PORT || "8088", 10);
const TARGET = { host: "127.0.0.1", port: 8080 };
if (!SECRET) {
console.error("ANIMUS_WEBAPI_TOKEN is not set; refusing to start");
process.exit(1);
}
http
.createServer((req, res) => {
if (req.headers.authorization !== `Bearer ${SECRET}`) {
res.writeHead(401, { "content-type": "application/json" });
return res.end('{"error":"unauthorized"}');
}
const headers = { ...req.headers };
delete headers.authorization;
// The transport answers only requests addressed to this machine.
headers.host = `${TARGET.host}:${TARGET.port}`;
delete headers.origin;
const upstream = http.request({ ...TARGET, path: req.url, method: req.method, headers }, (up) => {
res.writeHead(up.statusCode || 502, up.headers);
up.pipe(res);
});
upstream.on("error", () => {
res.writeHead(502, { "content-type": "application/json" });
res.end('{"error":"bad_gateway"}');
});
req.pipe(upstream);
})
.listen(PORT, "0.0.0.0");Keep the Host rewrite: the transport refuses (403) any request whose Host isn't localhost, a 127.x.x.x address or [::1] (see Security model).
The daemon and the transport are co-located in one container here. For higher availability, split them: one container runs the daemon without a transport, another runs the transport pointed at the daemon's control socket.
Required environment variables at runtime
| Variable | Required | Description |
|---|---|---|
ANTHROPIC_API_KEY | Yes (claude provider) | Provider API key; injected into the plugin's env via process env |
ANIMUS_WEBAPI_TOKEN | Yes (forwarder) | Bearer token the forwarder requires on every request |
WEBAPI_PORT | No (default 8088) | Port the forwarder listens on |
Keychain unavailable in containers. The OS keychain is not available in typical container environments. Secrets must reach the daemon and its plugins via process environment variables. The keychain layer is a no-op when the process env already carries the value, so existing workflows work without change. See Secrets reference.
3. Deploying to Railway
railway.toml
Place this file alongside your Dockerfile:
[build]
builder = "dockerfile"
dockerfilePath = "deploy/Dockerfile"
[deploy]
startCommand = "/app/deploy/start.sh"
restartPolicyType = "on_failure"
restartPolicyMaxRetries = 3Two-service topology (recommended)
The HTTP transport plugin has no built-in authentication. Never assign the Animus daemon service a public Railway domain. Instead use Railway's private network (*.railway.internal) and front it with a separate authenticated proxy or gateway service.
┌──────────────────────────────────────────────┐
│ Railway project │
│ │
│ Service: animus (PRIVATE — no public URL) │
│ ┌────────────────────────────────────────┐ │
│ │ animus daemon run (foreground) │ │
│ │ animus web serve (transport plugin) │ │ ← binds 127.0.0.1:8080
│ │ webapi-forward.js (checks token) │ │ ← listens 0.0.0.0:8088
│ └────────────────────────────────────────┘ │
│ ↑ Railway private network │
│ Service: gateway (PUBLIC) │
│ ┌────────────────────────────────────────┐ │
│ │ Hono / Express / nginx / etc. │ │ ← public HTTPS domain
│ │ Verifies caller auth │ │
│ │ Adds Bearer header, forwards to │ │
│ │ http://animus.railway.internal:8088 │ │
│ └────────────────────────────────────────┘ │
└──────────────────────────────────────────────┘In the gateway service, reach the Animus webapi through the forwarder using the Railway private DNS name: http://<service-name>.railway.internal:8088. The gateway must add Authorization: Bearer <ANIMUS_WEBAPI_TOKEN> to every forwarded request.
Environment variables
Set these in the Railway dashboard under the private animus service:
| Variable | Description |
|---|---|
ANTHROPIC_API_KEY | Claude provider API key (or the key for your chosen provider) |
ANIMUS_WEBAPI_TOKEN | Bearer token the forwarder requires on every request |
On the public gateway service, set at minimum:
| Variable | Description |
|---|---|
ANIMUS_WEBAPI_URL | http://animus.railway.internal:8088 |
ANIMUS_WEBAPI_TOKEN | Must match the value set on the animus service |
Persistence on Railway
Add a Railway Volume mounted at /home/appuser/.animus (adjust the path to match your container user's $HOME). This persists scoped runtime state — queue, runs, artifacts — across redeploys.
If subjects must also survive redeploys, install a persistent subject backend plugin (e.g. a Postgres-backed backend) rather than relying on animus-subject-default, which stores subjects on the container's local disk.
4. The HTTP Web API
Installing the transport
# Installs transport-http (required), transport-graphql, and web-ui (recommended).
animus plugin install-defaults --include-transports
# Or install only the HTTP transport:
animus plugin install animus-ecosystem/animus-transport-httpStarting the transport
animus web serveanimus web serve discovers and spawns installed transport_backend and web_ui plugins. It does not start an in-process HTTP server — the plugin binds its own port. If no transport plugins are installed, the command exits non-zero and prints the install command. The transport reports its bound URL through the plugin's JSON-RPC initialize handshake.
Bind address
Under animus web serve the HTTP transport binds 127.0.0.1:8080. web serve passes no bind address and does not read a transports block from .animus/config.json, and the plugin host does not forward ANIMUS_TRANSPORT_HTTP_BIND to the plugin. To reach the API from another container, publish it through a forwarder (Section 2).
REST surface — /api/v1
All endpoints return the same JSON envelope:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "some_error_code", "message": "..." } }Authentication
Requests through the forwarder (Section 2, port 8088) must carry ANIMUS_WEBAPI_TOKEN as a Bearer token. The transport itself ignores the header, so the examples below also work as-is inside the container against localhost:8080:
curl -H "Authorization: Bearer $ANIMUS_WEBAPI_TOKEN" http://localhost:8080/api/v1/subjectsThe transport plugin itself has no built-in authentication. In production always front it with an authenticated gateway (see Section 3).
Create a subject
curl -s -X POST http://localhost:8080/api/v1/subjects \
-H "Authorization: Bearer $ANIMUS_WEBAPI_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"kind": "task",
"title": "Implement feature X",
"body": "Detailed description here",
"status": "ready",
"labels": ["team:backend", "priority:high"]
}'Response:
{
"ok": true,
"data": {
"id": "task:TASK-001",
"kind": "task",
"title": "Implement feature X",
"status": "ready",
"labels": ["team:backend", "priority:high"],
"body": "Detailed description here",
"created_at": "2026-06-17T10:00:00Z"
}
}Get a subject by ID
curl -s "http://localhost:8080/api/v1/subjects/task%3ATASK-001" \
-H "Authorization: Bearer $ANIMUS_WEBAPI_TOKEN"URL-encode the : in the subject ID (task:TASK-001 → task%3ATASK-001).
List subjects by kind
curl -s "http://localhost:8080/api/v1/subjects?kind=task&limit=50" \
-H "Authorization: Bearer $ANIMUS_WEBAPI_TOKEN"The response envelope carries a subjects array. The typed list route does not support label filtering; use the plugin-call escape hatch below for that.
Update a subject's labels
curl -s -X POST "http://localhost:8080/api/v1/subjects/task%3ATASK-001" \
-H "Authorization: Bearer $ANIMUS_WEBAPI_TOKEN" \
-H "Content-Type: application/json" \
-d '{"labels_add": ["visibility:public"], "labels_remove": ["visibility:private"]}'Enqueue a subject for execution
Push a subject onto the daemon's dispatch queue. The daemon leases it and runs the project's configured workflow. This is more durable than triggering a workflow directly because it survives the HTTP request lifecycle and daemon restarts.
curl -s -X POST http://localhost:8080/api/v1/queue/enqueue \
-H "Authorization: Bearer $ANIMUS_WEBAPI_TOKEN" \
-H "Content-Type: application/json" \
-d '{"task_id": "task:TASK-001"}'Plugin-call escape hatch (server-side filtering)
curl -s -X POST "http://localhost:8080/api/v1/subject/animus-subject-default/call" \
-H "Authorization: Bearer $ANIMUS_WEBAPI_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"method": "subject/list",
"params": {
"kind": ["task"],
"labels_any": ["team:backend"]
}
}'Replace animus-subject-default with the installed subject backend plugin name. Use animus plugin list to see installed plugin names.
Check daemon health
curl -s "http://localhost:8080/api/v1/daemon/health" \
-H "Authorization: Bearer $ANIMUS_WEBAPI_TOKEN"Summary of confirmed REST endpoints
| Method | Path | Description |
|---|---|---|
POST | /api/v1/subjects | Create a subject (kind, title, body, status, labels) |
GET | /api/v1/subjects/:id | Get a subject by ID (URL-encode the : in the id) |
GET | /api/v1/subjects?kind=<k>&limit=<n> | List subjects by kind |
POST | /api/v1/subjects/:id | Update subject labels (labels_add, labels_remove) |
POST | /api/v1/subject/:plugin/call | Raw plugin RPC (method + params; use for label filtering) |
POST | /api/v1/queue/enqueue | Enqueue a subject (task_id) |
GET | /api/v1/daemon/health | Daemon health check |
Security model
In production:
- No public domain on the daemon service. Use private networking so the transport is only reachable from within the deployment project.
- An authenticated gateway sits in front, enforces caller auth, and adds
Authorization: Bearer $ANIMUS_WEBAPI_TOKENto every forwarded request. ANIMUS_WEBAPI_TOKENis a shared secret between the gateway and the transport-side forwarder. Set it via your platform's secret injection mechanism, not in source control.- The transport answers only requests addressed to this machine (animus-transport-http v0.2.4+).
Hostmust belocalhost, a127.x.x.xaddress or[::1], and a browserOrigin, when sent, must be one of those too; anything else gets403. This stops web pages in a local browser from driving the API through DNS rebinding. A proxy in front of the transport should sendHost: 127.0.0.1:8080; if it can't, list the names it passes on inANIMUS_TRANSPORT_ALLOWED_HOSTS(comma-separated, in the environment ofanimus web serve).
5. GraphQL (animus-transport-graphql)
Installing and starting
# Install the full transport set (HTTP required, GraphQL + web UI recommended):
animus plugin install-defaults --include-transports
# Or individually:
animus plugin install launchapp-dev/animus-transport-http
animus plugin install launchapp-dev/animus-transport-graphql
animus plugin install launchapp-dev/animus-web-uiStart all installed transport plugins at once:
animus web serveEach installed transport_backend plugin binds its own port and reports its URL at startup. The GraphQL transport typically binds at http://localhost:4000/graphql by default, but always check the animus web serve startup output for the exact address — it may vary by install configuration.
Introspecting the schema
Use any GraphQL client or run:
curl -s -X POST http://localhost:4000/graphql \
-H "Content-Type: application/json" \
-d '{"query":"{ __schema { types { name kind } } }"}'Or retrieve the SDL via the transport's plugin-call:
curl -s -X POST http://localhost:8080/api/v1/subject/animus-transport-graphql/call \
-H "Authorization: Bearer $ANIMUS_WEBAPI_TOKEN" \
-H "Content-Type: application/json" \
-d '{"method":"transport/schema","params":{}}'The GraphQL surface covers the operations the web UI consumes: workflows, subjects (tasks, requirements), queue, daemon control, skills, event subscriptions, and outputs. The full CLI + MCP surface is not covered by GraphQL — use the CLI or MCP tools for operations outside this set.
Example queries
Field names below reflect the documented subject model. Verify exact field names against the running schema introspection before using in production.
query ListTasks {
subjects(kind: "task", limit: 20) {
id
title
status
labels
createdAt
}
}
query GetWorkflows {
workflows {
id
status
currentPhase
}
}Example subscription
The GraphQL transport exposes live event subscriptions. The exact subscription transport (WebSocket or SSE) is version-specific — introspect the schema to confirm:
subscription WorkflowEvents {
workflowEvents {
workflowId
kind
phase
timestamp
}
}What is not locally verified
The animus-transport-graphql plugin is out-of-tree at launchapp-dev/animus-transport-graphql. The following specifics are not verifiable without a running instance:
- Exact bound port (check
animus web serveoutput). - Exact field names and types (introspect via the running endpoint).
- Auth model for the GraphQL endpoint (likely the same
ANIMUS_WEBAPI_TOKENbearer pattern; confirm withanimus plugin info --name animus-transport-graphql). - Subscription transport type (WebSocket vs SSE).
Use animus plugin info --name animus-transport-graphql and schema introspection to ground your client implementation against the installed version.
Quick Reference
# Install all transport plugins
animus plugin install-defaults --include-transports
# Verify all required plugin roles are satisfied
animus daemon preflight --project-root .
# Run daemon in foreground (correct for containers)
animus daemon run --project-root . &
# Start transport plugins
animus web serve --project-root .
# Health check via HTTP transport
curl http://localhost:8080/api/v1/daemon/health \
-H "Authorization: Bearer $ANIMUS_WEBAPI_TOKEN"
# Create a subject
curl -X POST http://localhost:8080/api/v1/subjects \
-H "Authorization: Bearer $ANIMUS_WEBAPI_TOKEN" \
-H "Content-Type: application/json" \
-d '{"kind":"task","title":"My task","status":"ready"}'
# Enqueue a subject for workflow execution
curl -X POST http://localhost:8080/api/v1/queue/enqueue \
-H "Authorization: Bearer $ANIMUS_WEBAPI_TOKEN" \
-H "Content-Type: application/json" \
-d '{"task_id":"task:TASK-001"}'
# Introspect GraphQL schema
curl -X POST http://localhost:4000/graphql \
-H "Content-Type: application/json" \
-d '{"query":"{ __schema { types { name } } }"}'