REST API reference
Ring is API-driven. Every CLI command is a thin client over this REST API; anything the CLI can do, you can do over HTTP.
Base URL
http://localhost:3030
The bind address and port are configurable per-context in config.toml.
Authentication
All requests except POST /login and GET /healthz require a Bearer token.
curl -H "Authorization: Bearer $TOKEN" http://localhost:3030/deployments
Get a token
POST /login Content-Type: application/json { "username": "admin", "password": "changeme" }
Response:
{ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." }
Tokens are stable per user; the CLI saves them in ~/.config/kemeter/ring/auth.json after ring login.
CORS
If cors_origins is configured in config.toml, the API serves the listed origins with GET, POST, PUT, DELETE, OPTIONS and the headers Authorization, Content-Type, Accept. Browser clients require this to be set explicitly.
Timeouts
Most endpoints are wrapped in a 10-second timeout, returning 408 Request Timeout if the handler runs longer. The streaming endpoint GET /deployments/{id}/logs (used with ?follow=true) is mounted in a separate router with no timeout, so SSE connections can stay open indefinitely.
System
GET /healthz
Check the server is up. Does not require authentication.
Response:
{ "state": "UP" }
Deployments
GET /deployments
List deployments.
Query parameters:
namespaceornamespace[]— filter by one or more namespacesstatusorstatus[]— filter by one or more statuseskindorkind[]— filter byworkerorjob(the CLI flag--typemaps to this)
Examples:
curl -H "Authorization: Bearer $TOKEN" \ http://localhost:3030/deployments curl -H "Authorization: Bearer $TOKEN" \ "http://localhost:3030/deployments?namespace[]=production&namespace[]=staging" curl -H "Authorization: Bearer $TOKEN" \ "http://localhost:3030/deployments?status[]=running&kind=worker"
Response:
[ { "id": "f3a8b2c4-...", "created_at": "2026-04-15T10:30:00Z", "updated_at": "2026-04-15T10:30:05Z", "status": "running", "restart_count": 0, "name": "nginx-demo", "runtime": "docker", "kind": "worker", "namespace": "default", "image": "nginx:1.25", "command": [], "config": {}, "replicas": 1, "ports": [], "labels": { "app": "nginx" }, "instances": ["abc123def456..."], "environment": { "ENV": "production" }, "volumes": [], "image_digest": "sha256:..." } ]
POST /deployments
Create a new deployment, or trigger a rolling update if one with the same name+namespace already exists.
Query parameters:
force=true— bypass the rolling-update path; immediately replace existing instances even when health checks are configured.
Body:
{ "name": "my-app", "runtime": "docker", "namespace": "production", "kind": "worker", "replicas": 3, "image": "nginx:1.25", "labels": { "app": "nginx", "version": "1.25" }, "environment": { "ENV": "production", "DEBUG": "false", "DATABASE_PASSWORD": { "secretRef": "database-password" } }, "volumes": [ { "type": "bind", "source": "/host/data", "destination": "/app/data", "driver": "local", "permission": "rw" } ] }
Environment values support two forms:
- Plain value —
"KEY": "value", passed as-is to the container. - Secret reference —
"KEY": { "secretRef": "secret-name" }, looks up an encrypted secret in the same namespace and injects it at deployment time. If the secret does not exist, the deployment is marked failed and anerrorevent is emitted.
Job example:
{ "name": "migration", "runtime": "docker", "namespace": "production", "kind": "job", "replicas": 1, "image": "myapp:latest", "command": ["npm", "run", "migrate"] }
Response: 201 Created with the full deployment object (same shape as GET /deployments/{id}).
GET /deployments/{id}
Retrieve a deployment by UUID.
Response:
{ "id": "f3a8b2c4-...", "created_at": "2026-04-15T10:30:00Z", "updated_at": "2026-04-15T10:30:05Z", "status": "running", "restart_count": 0, "name": "nginx-demo", "runtime": "docker", "kind": "worker", "namespace": "default", "image": "nginx:1.25", "command": [], "config": {}, "replicas": 1, "ports": [], "labels": { "app": "nginx" }, "instances": ["abc123def456..."], "environment": { "ENV": "production" }, "volumes": [ { "type": "bind", "source": "/host/data", "destination": "/app/data", "driver": "local", "permission": "rw" } ], "image_digest": "sha256:..." }
During a rolling update, the new (child) deployment carries a parent_id field referencing the old deployment.
DELETE /deployments/{id}
Delete a deployment. The deployment is marked deleted; its containers are removed by the scheduler on the next tick.
Response: 204 No Content
GET /deployments/{id}/logs
Tail or stream container logs.
Query parameters:
tail— last N lines (default: 100)since— relative duration (30s,10m,2h) or RFC3339 timestampcontainer— filter to one container/instance namefollow=true— return a Server-Sent Events (SSE) stream instead of a JSON array
Examples:
curl -H "Authorization: Bearer $TOKEN" \ "http://localhost:3030/deployments/$ID/logs?tail=50" curl -H "Authorization: Bearer $TOKEN" \ "http://localhost:3030/deployments/$ID/logs?since=10m" curl -H "Authorization: Bearer $TOKEN" \ "http://localhost:3030/deployments/$ID/logs?follow=true"
Response (default):
[ { "instance": "nginx-demo-1", "message": "nginx: starting server", "level": "info", "timestamp": "2026-04-15T10:30:00Z" } ]
When follow=true, the response is an SSE stream (Content-Type: text/event-stream) where each data: line carries the same JSON shape as a single log entry.
This route is mounted without the 10-second API timeout so streams can stay open.
GET /deployments/{id}/events
Retrieve scheduler events for a deployment.
Query parameters:
level— filter byinfo,warning, orerrorlimit— maximum number of events (default: 50)
Response:
[ { "id": "event-uuid", "deployment_id": "f3a8b2c4-...", "timestamp": "2026-04-15T10:30:00Z", "level": "info", "component": "scheduler", "reason": "ContainerStarted", "message": "Container nginx-demo-1 started successfully" } ]
GET /deployments/{id}/health-checks
Retrieve recent health-check results.
Query parameters:
limit— maximum number of resultslatest=true— only the most recent result per check
Response:
[ { "id": "550e8400-e29b-41d4-a716-446655440000", "deployment_id": "f3a8b2c4-...", "check_type": "tcp", "status": "success", "message": null, "created_at": "2026-04-15T10:30:00Z", "started_at": "2026-04-15T10:30:00Z", "finished_at": "2026-04-15T10:30:01Z" } ]
GET /deployments/{id}/metrics
Live resource usage for a deployment and each of its instances. Only meaningful for the Docker runtime — Cloud Hypervisor returns an empty instances list.
Response:
{ "deployment_id": "f3a8b2c4-...", "deployment_name": "nginx-demo", "instance_count": 3, "total_cpu_usage_percent": 2.5, "total_memory": { "usage_bytes": 52428800, "limit_bytes": 536870912, "usage_percent": 9.8 }, "total_network": { "rx_bytes": 1024000, "tx_bytes": 512000, "rx_packets": 1500, "tx_packets": 800 }, "total_disk_io": { "read_bytes": 2048000, "write_bytes": 1024000 }, "total_pids": 12, "instances": [ { "instance_id": "abc123", "instance_name": "nginx-demo-1", "cpu_usage_percent": 0.8, "memory": { "usage_bytes": 17476267, "limit_bytes": 178956970, "usage_percent": 9.8 }, "network": { "rx_bytes": 341333, "tx_bytes": 170667, "rx_packets": 500, "tx_packets": 267 }, "disk_io": { "read_bytes": 682667, "write_bytes": 341333 }, "pids": { "current": 4, "limit": 1024 }, "restart_count": 0 } ] }
Secrets
Secrets are AES-256-GCM-encrypted values stored per-namespace. The API never exposes the decrypted value — only metadata is returned.
The server must be started with RING_SECRET_KEY set (a base64-encoded 32-byte key). Without it, every secret endpoint returns 500 Internal Server Error.
POST /secrets
{ "namespace": "production", "name": "database-password", "value": "my-secret-value" }
Response: 201 Created
{ "id": "550e8400-e29b-41d4-a716-446655440000", "created_at": "2026-04-15T10:30:00Z", "namespace": "production", "name": "database-password" }
Errors:
409 Conflict— secret with this name already exists in this namespace500 Internal Server Error—RING_SECRET_KEYis not set on the server
GET /secrets
Query parameters:
namespaceornamespace[]
Response:
[ { "id": "550e8400-e29b-41d4-a716-446655440000", "created_at": "2026-04-15T10:30:00Z", "updated_at": null, "namespace": "production", "name": "database-password" } ]
GET /secrets/{id}
Returns the same shape as a list entry. Values are never returned.
DELETE /secrets/{id}
Query parameters:
force=true— delete even if referenced by active deployments
Response: 204 No Content
Errors:
404 Not Found— secret does not exist409 Conflict— secret is referenced by active deployments. Body lists them:
{ "error": "Secret is referenced by deployments", "deployments": ["production/web-app", "production/worker"], "hint": "Use ?force=true to delete anyway" }
Users
GET /users
[ { "id": "550e8400-e29b-41d4-a716-446655440000", "username": "admin", "created_at": "2026-04-15T10:00:00Z" } ]
POST /users
{ "username": "alice", "password": "secretpassword" }
Response: 201 Created
GET /users/me
Returns the user attached to the bearer token.
{ "id": "550e8400-e29b-41d4-a716-446655440000", "username": "admin", "created_at": "2026-04-15T10:00:00Z", "updated_at": null, "status": "active", "login_at": "2026-04-15T14:30:00Z" }
PUT /users/{id}
Update a user.
{ "username": "alice", "password": "newpassword" }
Response: 200 OK
DELETE /users/{id}
Response: 204 No Content
Configs
A config is a named blob (typically a config file or JSON document) attached to a namespace. Configs can be mounted into a deployment via a volume of type: config.
GET /configs
[ { "id": "config-uuid", "name": "app-config", "namespace": "production", "data": "{\"database\":{\"host\":\"localhost\"}}" } ]
POST /configs
{ "name": "app-config", "namespace": "production", "data": "{\"database\":{\"host\":\"localhost\",\"port\":5432}}" }
Response: 201 Created
GET /configs/{id}
{ "id": "config-uuid", "name": "app-config", "namespace": "production", "data": "{\"database\":{\"host\":\"localhost\",\"port\":5432}}", "labels": "" }
PUT /configs/{id}
Full replacement (not partial). All fields must be provided.
{ "name": "app-config", "data": "{\"database\":{\"host\":\"new-host\",\"port\":5432}}", "labels": "env=production" }
Response: 200 OK
DELETE /configs/{id}
Response: 204 No Content
Namespaces
GET /namespaces
[ { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "name": "default", "created_at": "2026-04-15T10:00:00Z", "updated_at": null } ]
GET /namespaces/{id}
{ "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "name": "production", "created_at": "2026-04-15T09:00:00Z", "updated_at": null }
Errors: 404 Not Found if the namespace doesn't exist.
POST /namespaces
{ "name": "staging" }
Response: 201 Created
{ "id": "c3d4e5f6-...", "name": "staging", "created_at": "2026-04-15T14:00:00Z", "updated_at": null }
Errors: 409 Conflict if a namespace with the same name already exists.
Namespaces are also auto-created when a deployment is applied to a non-existent namespace; calling
POST /namespacesupfront is optional.
Node
GET /node/get
Returns information about the host running the Ring server.
{ "hostname": "ring-server", "os": "linux", "arch": "x86_64", "uptime": "428000s", "cpu_count": 8, "memory_total": 16.0, "memory_available": 11.2, "load_average": [0.42, 0.51, 0.55] }
memory_total and memory_available are in GiB. load_average is [1m, 5m, 15m].
HTTP status codes
200 OK— successful request returning a body201 Created— resource created204 No Content— successfulDELETE400 Bad Request— invalid request body or query parameters401 Unauthorized— missing or invalid bearer token403 Forbidden— authenticated but not allowed404 Not Found— resource does not exist408 Request Timeout— handler exceeded the 10-second API timeout409 Conflict— duplicate resource or conflicting state500 Internal Server Error— server-side failure (e.g.RING_SECRET_KEYnot set on a secret operation)
Error format
{ "error": "Deployment not found", "code": "DEPLOYMENT_NOT_FOUND" }
Some endpoints attach contextual fields — for instance, DELETE /secrets/{id} returns the list of referencing deployments under the deployments key.
Examples
CI deployment via raw API
TOKEN=$(curl -s -X POST "$RING_URL/login" \ -H "Content-Type: application/json" \ -d "{\"username\":\"$RING_USER\",\"password\":\"$RING_PASSWORD\"}" \ | jq -r '.token') curl -X POST "$RING_URL/deployments" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d @deployment.json
Stream events into stdout
curl -N -H "Authorization: Bearer $TOKEN" \ "$RING_URL/deployments/$ID/logs?follow=true"
-N disables curl's output buffering so SSE lines are flushed immediately.
Webhooks
Ring does not support outbound webhooks. To observe state changes, poll the relevant endpoint, or open an SSE stream against GET /deployments/{id}/logs?follow=true and subscribe to events with GET /deployments/{id}/events plus periodic refresh.