Skip to main content
A Project is the core workload unit in Caravanserai. It groups one or more containers (services) that run together on a single node, sharing a Docker bridge network. Service names resolve as hostnames inside that network, identical to Docker Compose.
1

Write a project manifest

Create a YAML file that describes your workload. At minimum you need apiVersion, kind, metadata.name, and at least one service under spec.services.
project.yaml
apiVersion: caravanserai/v1
kind: Project
metadata:
  name: my-project
spec:
  services:
    - name: web
      image: nginx:alpine
      env:
        - name: APP_ENV
          value: production
  volumes: []
The fields you can set in spec:
FieldRequiredDescription
servicesYesOrdered list of containers to run.
services[].nameYesService name — also the DNS hostname inside the bridge network.
services[].imageYesDocker image reference, e.g. postgres:15.
services[].envNoEnvironment variables injected at container start.
services[].volumeMountsNoNamed volumes to mount into the container.
volumesNoNamed volume definitions shared across services.
ingressNoHTTP routing rules for internal traffic.
expireAtNoRFC 3339 timestamp after which the GC controller deletes the project.

Multi-service example: WordPress

The following manifest runs MySQL and WordPress together. The app service reaches the database using the hostname db — resolved automatically over the shared bridge network.
wordpress.yaml
apiVersion: caravanserai/v1
kind: Project
metadata:
  name: wordpress
spec:
  services:
    - name: db
      image: mysql:8
      env:
        - name: MYSQL_ROOT_PASSWORD
          value: "secret"
        - name: MYSQL_DATABASE
          value: "wp"
      volumeMounts:
        - name: mysql-data
          mountPath: /var/lib/mysql
    - name: app
      image: wordpress:latest
      env:
        - name: WORDPRESS_DB_HOST
          value: db          # resolves via the shared bridge network
        - name: WORDPRESS_DB_PASSWORD
          value: "secret"
        - name: WORDPRESS_DB_NAME
          value: "wp"
  volumes:
    - name: mysql-data
      type: Ephemeral
Ephemeral is currently the only supported volume type. Ephemeral volumes are discarded when the project is stopped or moved to a different node.
2

Apply the manifest

Run caractrl apply to send the manifest to the control plane. The API server creates the project record and sets its initial phase to Pending.
caractrl apply -f project.yaml
You should see output like:
project "my-project" created
3

Watch the phase change

Poll the project status to track its progress through the lifecycle:
caractrl get projects my-project
Example output:
NAME         PHASE     NODE      CONDITIONS          AGE
my-project   Running   node-01   ContainersRunning   12s
The PHASE column reflects the current lifecycle state:
PhaseSet byMeaning
PendingServerAccepted by the API; the scheduler has not yet chosen a node.
ScheduledSchedulerA node has been assigned; the agent has not confirmed containers are up.
RunningAgentAll containers are running.
FailedAgentThe agent could not start the project or reported a terminal error.
TerminatingServerA deletion request was received; the agent is tearing down containers.
TerminatedAgentAll containers and Docker resources have been removed.
Filter the project list by phase to focus on what matters:
caractrl get projects --phase=Running
caractrl get projects --phase=Pending
Valid values for --phase are: Pending, Scheduled, Running, Failed, Terminating.
4

Check conditions if something goes wrong

If your project stays in Pending or moves to Failed, inspect the conditions array for a detailed reason. Use --output json to see the full status object:
caractrl --output json get projects my-project
Look for the status.conditions array in the output:
{
  "status": {
    "phase": "Failed",
    "nodeRef": "node-01",
    "conditions": [
      {
        "type": "ContainersRunning",
        "status": "False",
        "reason": "ImagePullFailed",
        "message": "failed to pull image \"myapp:typo\": not found"
      }
    ]
  }
}
Each condition has a type, status (True / False), a machine-readable reason, and a human-readable message.
5

View the assigned node

Once the scheduler picks a node, status.nodeRef is populated with the node name. You can see it in the JSON output:
caractrl --output json get projects my-project
{
  "status": {
    "phase": "Running",
    "nodeRef": "node-01"
  }
}
Use the node name to inspect that node’s capacity and health:
caractrl get nodes node-01
6

Clean up

Delete a project when you no longer need it. The control plane transitions the project to Terminating and signals the agent to tear down all containers, networks, and volumes.
caractrl delete project my-project
If the agent acknowledges the teardown synchronously, you see:
project "my-project" deleted
Otherwise you see:
project "my-project" is being deleted
In either case, the project disappears from the list once the agent sets the phase to Terminated and the termination controller removes the record.

Common issues

The scheduler only assigns work to nodes in the Ready state. If no ready nodes exist in the cluster, the project waits indefinitely.Check your node states:
caractrl get nodes
If all nodes are NotReady, verify that cara-agent is running on at least one machine and that its heartbeats are reaching the control plane. See Node management for details.
A Failed phase means the agent encountered a terminal error it cannot recover from on its own. The most common causes are:
  • Image pull failure — the image name or tag is wrong, or the registry is unreachable from the node.
  • Port conflict — another container on the node already occupies a required host port.
  • Volume error — the Docker daemon could not create the volume.
Retrieve the full condition list to find the specific reason:
caractrl --output json get projects my-project
Fix the underlying issue, then delete and re-apply the project — there is no in-place restart.

Ephemeral environments with spec.expireAt

Set spec.expireAt to an RFC 3339 timestamp to create a project that the GC controller automatically deletes after that time. This is useful for preview or CI environments that should not outlast a fixed window.
apiVersion: caravanserai/v1
kind: Project
metadata:
  name: pr-preview-42
spec:
  expireAt: "2026-04-07T00:00:00Z"
  services:
    - name: web
      image: myapp:pr-42
Once the timestamp passes, the GC controller deletes the project exactly as if you had run caractrl delete project pr-preview-42.