Operations-first admin dashboard for the Cycles AI agent governance platform — visualize tenant budgets, action-authority enforcement, reservations, and webhook delivery in real time. Multi-tenant by default, designed around operator workflows for incident response, not CRUD entity lists.
Pairs with the Cycles Admin API and the Cycles Server to provide end-to-end observability into agent spend, risk, and tool action enforcement. Aligned with governance spec v0.1.25.34.
Documentation: CHANGELOG (downstream release notes) · OPERATIONS (production runbook) · AUDIT (engineering narrative).

End-to-end walkthrough of the main operator flows
Operations-first dashboard for monitoring and managing the Cycles budget enforcement platform. Designed around operator workflows, not CRUD entity lists.
| Page | Purpose |
|---|---|
| Overview | Operational health at a glance — single-request aggregated dashboard |
| Tenants | Tenant list + detail with budgets, API keys, and policies tabs |
| Budgets | Tenant-scoped budget list with utilization/debt bars + exact scope detail |
| Events | Correlation-first investigation tool with expandable detail rows |
| API Keys | Cross-tenant key list with masked IDs, permissions, status filters |
| Webhooks | Subscription health (green/yellow/red) + delivery history |
| Reservations | Hung-reservation force-release during incident response (runtime-plane admin-on-behalf-of); committed/finalized columns, metadata detail, time-range + Subject filters |
| Audit | Compliance query tool with CSV/JSON export (manual-only, no auto-refresh) |
| Evidence | Retrieve + inspect a signed evidence envelope by id; signer-key resolution against the published JWK Set. Authenticated operator lookup — the underlying envelope retrieval is a public, content-addressed runtime API |
Tier 1 incident-response actions available directly from the dashboard (capability-gated, confirmation required):
| Action | Where | Effect |
|---|---|---|
| Freeze budget | Budget detail | Blocks all reservations, commits, and fund operations |
| Unfreeze budget | Budget detail | Re-enables normal operations |
| Create budget | Budgets list, Tenant detail | Admin-on-behalf-of (dual-auth) — modal with ScopeBuilder + tenant selector |
| Adjust budget allocation | Budget detail | Inline form — uses fund endpoint with RESET operation |
| Rollover billing period (RESET_SPENT) | Budget detail → Fund → RESET_SPENT | Resets spent tally without touching allocated; optional exact-spent override (blank = zero). Requires cycles-server-admin v0.1.25.18+ |
| Bulk budget action (CREDIT / DEBIT / RESET / RESET_SPENT / REPAY_DEBT) | Budgets list | Filter-apply — single tenant required (spec constraint); preview walk + expected_count gate + per-row result dialog for failed/skipped rows. Requires cycles-server-admin v0.1.25.29+ |
| Emergency Freeze (tenant-wide) | Tenant detail | Sequential freeze across all ACTIVE budgets — one-click lockdown with confirm + blast-radius summary |
| Create policy | Policies tab (Tenant detail) | Admin-on-behalf-of — modal form, tenant-scoped |
| Edit policy | Policies tab | Admin-on-behalf-of — patch policy_id, server resolves owning tenant |
| Suspend tenant | Tenant detail | Blocks all API access for the tenant |
| Reactivate tenant | Tenant detail | Restores API access |
| Bulk suspend / reactivate tenants | Tenants list | Multi-select + bulk action bar with sequential per-tenant calls, live progress, cancel-between-requests |
| Create tenant | Tenants list | Modal form, navigates to new tenant on success |
| Edit tenant | Tenant detail | Edit display name |
| Revoke API key | API Keys list, Tenant detail | Immediately invalidates the key (irreversible) |
| Create API key | API Keys list, Tenant detail | Modal form with permissions, shows secret once |
| Edit API key | API Keys list | Edit name, permissions, scope filter |
| Pause webhook | Webhook detail | Stops event deliveries; events silently dropped |
| Enable webhook | Webhook detail | Resumes deliveries (resets failure counter) |
| Reset & re-enable webhook | Webhook detail | Re-enables disabled/failing webhook, clears failures |
| Bulk pause / enable webhooks | Webhooks list | Multi-select + tenant filter; sequential per-sub with cancel. Auto-disabled webhooks excluded from bulk Enable (per-row verification required) |
| Create webhook | Webhooks list | Modal form, shows signing secret once |
| Delete webhook | Webhook detail | Permanent deletion with confirmation |
| Test webhook | Webhook detail | Sends synthetic test event, shows result inline |
| Replay events | Webhook detail | Re-deliver events for a time range |
| Force release reservation | Reservations | Runtime-plane admin-on-behalf-of — pre-filled [INCIDENT_FORCE_RELEASE] reason for audit grep-ability; surfaces a "View evidence" link when the server emits a cycles_evidence reference |
src/
├── api/ # API client (X-Admin-API-Key only)
├── components/ # Reusable UI: Sidebar, PageHeader, StatusBadge, SortHeader, EmptyState, etc.
├── composables/ # usePolling, useSort, useDarkMode, useTerminalAwareList, useChartTheme
├── stores/ # Pinia: auth (introspect + capabilities)
├── views/ # route views (login, overview, budgets, events, api-keys, webhooks, audit, tenants, reservations, evidence + detail views)
└── types.ts # TypeScript types matching governance spec schemas
- Framework: Vue 3 + TypeScript + Vite
- State: Pinia
- Styling: Tailwind CSS v4 with dark mode support
- Testing: Vitest + @vue/test-utils (unit); Playwright (E2E against live compose stack)
- Router: Vue Router 4 with auth guard
- Security: SRI hashes (
vite-plugin-sri-gen), CSP + HSTS headers, login rate limiting
Requires both backends running locally:
- cycles-server-admin at
localhost:7979— governance plane (tenants, budgets, policies, webhooks, audit, introspect). - cycles-server at
localhost:7878— runtime plane (reservations; force-release uses admin-on-behalf-of dual-auth).
npm install
npm run devDashboard starts at http://localhost:5173. The Vite dev server splits the proxy between the runtime and governance planes:
/v1/reservations*,/v1/evidence*,/v1/.well-known/cycles-jwks.json→localhost:7878(cycles-server, runtime plane)/v1/*(everything else) →localhost:7979(cycles-server-admin, governance plane)
The same routing split is mirrored in default.conf.template for the
production container, where the two upstreams are configurable via the
ADMIN_UPSTREAM / RUNTIME_UPSTREAM environment variables.
# Start admin server + Redis
cd ../cycles-server-admin
ADMIN_API_KEY=your-key docker compose up -d
# Start dashboard
cd ../cycles-dashboard
npm install
npm run devSee Production Deployment below. The recommended setup uses Caddy for automatic HTTPS:
cp Caddyfile.example Caddyfile # edit domain
# create .env with ADMIN_API_KEY, REDIS_PASSWORD, etc.
docker compose -f docker-compose.prod.yml up -dCaddyfile is local deployment configuration and is intentionally ignored by
git; keep Caddyfile.example as the committed template.
Only ports 443 and 80 are exposed. All internal services (dashboard, admin server, Redis) communicate over the Docker network.
The dashboard uses AdminKeyAuth exclusively (X-Admin-API-Key header). No tenant API keys are used.
- User enters admin API key on the login page
- Dashboard calls
GET /v1/auth/introspectto validate and retrieve capabilities - Sidebar navigation is gated by capability booleans (
view_overview,view_budgets, etc.) - On 401/403 from any API call, the session is cleared and user is redirected to login
- API key is stored in
sessionStorage— survives page refresh, cleared on tab/browser close - Session idle timeout (30 min) and absolute timeout (8 h) enforced client-side (checked every 15s)
- Login rate limiting — exponential backoff after 3 failed attempts (5s → 60s cap)
| Endpoint | Page | Notes |
|---|---|---|
GET /v1/auth/introspect |
Login | Auth validation + capability discovery |
GET /v1/admin/overview |
Overview | Single-request aggregated dashboard payload |
GET /v1/admin/tenants |
Tenants | Tenant list |
GET /v1/admin/tenants/{id} |
Tenant Detail | Single tenant |
GET /v1/admin/budgets |
Budgets | Tenant-scoped list (requires tenant_id param) |
GET /v1/admin/budgets/lookup |
Budget Detail | Exact (scope, unit) lookup |
GET /v1/admin/events |
Events | Filtered event stream |
GET /v1/admin/webhooks |
Webhooks | Subscription list |
GET /v1/admin/webhooks/{id} |
Webhook Detail | Single subscription |
GET /v1/admin/webhooks/{id}/deliveries |
Webhook Detail | Delivery history |
GET /v1/admin/audit/logs |
Audit | Manual query with export |
GET /v1/admin/api-keys |
Tenant Detail | API keys per tenant |
GET /v1/admin/policies |
Tenant Detail | Policies per tenant (requires tenant_id) |
POST /v1/admin/budgets/freeze |
Budget Detail | Freeze budget (ACTIVE → FROZEN) |
POST /v1/admin/budgets/unfreeze |
Budget Detail | Unfreeze budget (FROZEN → ACTIVE) |
PATCH /v1/admin/tenants/{id} |
Tenant Detail | Suspend / reactivate tenant |
DELETE /v1/admin/api-keys/{key_id} |
API Keys, Tenant Detail | Revoke API key |
PATCH /v1/admin/webhooks/{subscription_id} |
Webhook Detail | Pause/enable, reset failures |
DELETE /v1/admin/webhooks/{subscription_id} |
Webhook Detail | Delete webhook subscription |
POST /v1/admin/webhooks/{subscription_id}/test |
Webhook Detail | Send test event |
POST /v1/admin/webhooks/{subscription_id}/replay |
Webhook Detail | Replay historical events |
POST /v1/admin/budgets/fund |
Budget Detail | Adjust allocation (RESET operation) |
GET /v1/reservations |
Reservations | Tenant-scoped list; supports include=, created/expires/finalized ranges, Subject filters |
GET /v1/reservations/{id} |
Reservations | Detail incl. committed_metadata / reserve metadata + evidence projections (one-click "View evidence" links; needs cycles-server v0.1.25.37+) |
POST /v1/reservations/{id}/release |
Reservations | Force-release; response cycles_evidence surfaces a "View evidence" link |
GET /v1/evidence/{evidence_id} |
Evidence | Public signed-envelope retrieval (runtime plane) |
GET /v1/.well-known/cycles-jwks.json |
Evidence | Signer JWK Set for signer-key resolution (runtime plane) |
Every top-level list view (Tenants, Budgets, Webhooks, API Keys) and the TenantDetail sub-lists share a hide-terminal-by-default pattern (v0.1.25.46+). Terminal-state rows are hidden at mount and surfaced via a "Show <verb>" toggle in the filter row:
| Entity | Terminal states | Toggle label | URL param |
|---|---|---|---|
| Tenant | CLOSED |
Show closed (N) | ?include_terminal=1 |
| Budget | CLOSED |
Show closed (N) | ?include_terminal=1 |
| Webhook | DISABLED |
Show disabled (N) | ?include_terminal=1 |
| API Key | REVOKED, EXPIRED |
Show revoked (N) | ?include_terminal=1 |
- Why: under default
created_at descsort, freshly-terminal rows pin to the top and visually compete with rows that still need operator action. Matches the Gmail / GitHub / Linear "hide done / archived" convention. - Auto-engage: picking a terminal value from the status dropdown (e.g.
status=CLOSED) auto-reveals those rows so the operator doesn't see an empty list (same pattern as GitHub'sstate:closed). - Sink order: when toggled on, terminal rows appear at the bottom of the visible list via stable partition (column-sort order preserved within each group).
- Export / select-all / counter: all read from the post-terminal-filter visible list. CSV/JSON export never includes hidden terminals; bulk actions never silently touch a hidden row.
Shared implementation: src/composables/useTerminalAwareList.ts.
The dashboard renders inline charts alongside the data tables via Apache
ECharts (vue-echarts). The charting layer landed as a trial slice in
v0.1.25.47 (single donut) and expanded through v0.1.25.48 – v0.1.25.50
to three Overview donuts: Budget status distribution (lifecycle
mix), Budget fleet utilization (true-utilization buckets —
Healthy < 90% / Near cap 90–99% / Over cap ≥ 100%, computed from
spent/allocated rather than the debt-based is_over_limit server
signal), and Events by category (recent-window activity mix).
v0.1.25.51 added a webhook fleet-health donut
(Healthy / Failing / Paused / Disabled) and a four-up
per-subscription stat row on WebhookDetailView (last-success
band, delivery-outcome donut, attempts histogram, response-time
p50/p95/max) — all derived from the data polls already in flight.
v0.1.25.52 relocated the webhook fleet-health donut from
WebhooksView to the Overview chart row (now 4-up on lg:
budget utilization → webhook fleet health → events by category
→ top-10 by debt) so WebhooksView keeps the table above the
fold for row-level triage; WebhookDetailView stat row stays on
the detail view (per-subscription detail belongs with the
subscription). Subsequent slices extend the pattern to API Keys /
Events views.
Shared building blocks:
| File | Role |
|---|---|
src/components/BaseChart.vue |
Shared wrapper. Props: option, label (accessibility), height. Tree-shaken ECharts registrations — only chart types in use are bundled. |
src/composables/useChartTheme.ts |
Reactive palette mapping the Tailwind status tokens (success / warning / danger / info / neutral) plus axis / grid / tooltip colors to ECharts values. Re-derives on dark-mode toggle. |
ECharts is lazy-loaded per-view via defineAsyncComponent so the chart
bundle downloads only when a chart actually renders. No view's initial
chunk pays the chart-library cost. v0.1.25.51 re-registered BarChart +
GridComponent (removed in v0.1.25.50 when all three Overview charts
became donuts) because WebhookDetailView introduces an attempts-
per-delivery bar chart. Active registrations: PieChart, BarChart,
TooltipComponent, LegendComponent, GridComponent.
Every chart reads data the view already fetched — no chart adds a
network request beyond what the attention cards above already drive.
Charts are also clickable: slices emit slice-click which the
parent view maps to router.push with the corresponding list-view
filter pre-applied. Current drill-down contracts:
- Budget status donut → Budgets filtered by
status=ACTIVE|FROZEN|CLOSEDorfilter=over_limit. - Budget fleet utilization donut → Budgets filtered by
utilization_min/utilization_max(integer percent, 0–100).BudgetsViewhydrates both params from the URL on mount. - Events by category donut → Events filtered by
category=<name>. - Webhook fleet-health donut → Webhooks filtered by
status=ACTIVE|PAUSED|DISABLEDorfailing=1(the Failing slice is orthogonal to status — aPAUSEDwebhook withconsecutive_failures ≥ 1still counts as Failing so the chart and thefailing=1filter match). As of v0.1.25.53status=…is pushed to the server (listWebhookSubscriptionsstatusparam) so drill-down counts reconcile with the Overview counter-strip tiles. - Delivery-outcome donut (WebhookDetailView) → local status filter on the history table, no route push.
For the full six-slice roadmap and what each view is expected to
visualize, see AUDIT.md → v0.1.25.47 charting layer.
Each page manages its own polling lifecycle via the usePolling composable:
| Page | Interval | Behavior |
|---|---|---|
| Overview | 30s | Pause on tab hidden, 2x backoff on error (max 5min) |
| Budgets | 60s | Same |
| Events | 15s | Same |
| Webhooks | 60s | Same |
| Tenants | 60s | Same |
| Audit | Manual only | Explicit "Run Query" button |
npm run build # Type-check + production build → dist/
npm run test # Run Vitest unit tests
npm run dev # Development server with HMR
npm run preview # Preview production build locallyTwo layers run against the live docker-compose stack:
- HTTP probes (
scripts/e2e-probes.sh) — curl through the dashboard nginx, verify routing + response shape. - Playwright (
tests/e2e/) — drive a real Chromium through critical user flows (login, reservation force-release, sort accessor).
Run locally:
# One-time: install Playwright's Chromium + OS deps
npm run test:e2e:install
# Bring up the full stack (admin + runtime + redis + dashboard on :8080)
ADMIN_API_KEY=admin-bootstrap-key docker compose -f docker-compose.yml up -d --wait
# Run both layers:
bash scripts/e2e-probes.sh
npm run test:e2e
# Interactive UI (pick tests, see traces inline):
npm run test:e2e:ui
# Tear down
docker compose -f docker-compose.yml down -vBoth layers are wired into .github/workflows/e2e.yml — runs nightly and on PRs that touch nginx, Dockerfile, compose, the API client, tests/e2e/, or the workflow/probe files.
Multi-stage build: Node 20 for npm run build, then nginx:alpine to serve.
# Build
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Serve
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY default.conf.template /etc/nginx/templates/default.conf.template
ENV ADMIN_UPSTREAM=http://cycles-admin:7979 \
RUNTIME_UPSTREAM=http://cycles-server:7878The nginx config handles SPA routing (try_files $uri /index.html) and reverse-proxies /v1/* to the admin server, except the runtime-plane routes (/v1/reservations/*, /v1/evidence/*, /v1/.well-known/cycles-jwks.json) which go to the runtime server. It ships as an envsubst template so the ADMIN_UPSTREAM / RUNTIME_UPSTREAM upstreams can be retargeted at deploy time without rebuilding the image — see OPERATIONS.md.
┌─────────────┐
Browser ──HTTPS──▶ │ TLS Proxy │──HTTP──▶ Dashboard (nginx:80)
│ (Caddy/ALB) │ │
└─────────────┘ /v1/ split-proxy
│ │
/v1/reservations, /v1/evidence, │ │ /v1/*
/v1/.well-known/cycles-jwks.json ▼ ▼ (everything else)
Runtime Server (:7878) Admin Server (:7979)
│ │
└────────► Redis (:6379) ◄┘
The dashboard is a static SPA served by nginx. API calls are reverse-proxied through the same nginx to two backend planes: the governance/admin server (:7979, default — tenants, budgets, policies, webhooks, audit, introspect) and the runtime server (:7878 — reservations, evidence, and the signer JWKS). Both must be reachable; the split is configured via ADMIN_UPSTREAM / RUNTIME_UPSTREAM. In production, a TLS-terminating proxy sits in front.
This mirrors the canonical docker-compose.prod.yml —
treat that file as the source of truth. The dashboard image bundles an nginx
proxy that splits /v1/* between the governance plane (cycles-admin) and the
runtime plane (cycles-server — reservations, evidence, JWKS), so both
backends must be present and reachable.
services:
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "443:443"
- "80:80"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy-data:/data
depends_on:
dashboard:
condition: service_healthy
networks:
- cycles
dashboard:
image: ghcr.io/runcycles/cycles-dashboard:0.1.25.67
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1/"]
interval: 30s
timeout: 3s
retries: 3
start_period: 5s
# No exposed ports — only accessible through Caddy. nginx proxies
# /v1/* to both planes; override the upstreams only for split hosts.
environment:
ADMIN_UPSTREAM: ${ADMIN_UPSTREAM:-http://cycles-admin:7979}
RUNTIME_UPSTREAM: ${RUNTIME_UPSTREAM:-http://cycles-server:7878}
depends_on:
cycles-admin:
condition: service_healthy
cycles-server:
condition: service_healthy
networks:
- cycles
cycles-admin:
image: ghcr.io/runcycles/cycles-server-admin:0.1.25.48
restart: unless-stopped
environment:
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD must be set}
ADMIN_API_KEY: ${ADMIN_API_KEY:?ADMIN_API_KEY must be set}
WEBHOOK_SECRET_ENCRYPTION_KEY: ${WEBHOOK_SECRET_ENCRYPTION_KEY:?WEBHOOK_SECRET_ENCRYPTION_KEY must be set}
WEBHOOK_SECRET_ENCRYPTION_REQUIRED: "true"
JAVA_OPTS: "-XX:MaxRAMPercentage=75 -XX:+UseG1GC -XX:+UseStringDeduplication"
DASHBOARD_CORS_ORIGIN: ${DASHBOARD_ORIGIN:-https://admin.example.com}
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:7979/actuator/health/readiness"]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
depends_on:
redis:
condition: service_healthy
networks:
- cycles
# Runtime plane — serves /v1/reservations, /v1/evidence, and the signer
# JWKS the dashboard's Reservations + Evidence views consume. Pinned to
# .37: .36+ surfaces reservation committed/finalized/metadata, .37+ adds
# the include=evidence projection (reservation->evidence links). Older
# versions degrade gracefully (fields omitted, no links). Evidence signing
# is not enabled by the default compose file; use docker-compose.override.yml
# for the local demo identity, or set the CyclesEvidence env vars on both
# cycles-server and cycles-events in your deployment.
cycles-server:
image: ghcr.io/runcycles/cycles-server:0.1.25.46
restart: unless-stopped
environment:
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD must be set}
ADMIN_API_KEY: ${ADMIN_API_KEY:?ADMIN_API_KEY must be set}
JAVA_OPTS: "-XX:MaxRAMPercentage=75 -XX:+UseG1GC -XX:+UseStringDeduplication"
CYCLES_METRICS_TENANT_TAG_ENABLED: "false"
SPRINGDOC_API_DOCS_ENABLED: "false"
SPRINGDOC_SWAGGER_UI_ENABLED: "false"
DASHBOARD_CORS_ORIGIN: ${DASHBOARD_ORIGIN:-https://admin.example.com}
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:7878/actuator/health/readiness"]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
depends_on:
redis:
condition: service_healthy
networks:
- cycles
# Webhook-delivery worker (consumes events from Redis, fans out to
# subscriber endpoints).
cycles-events:
image: ghcr.io/runcycles/cycles-server-events:0.1.25.22
restart: unless-stopped
environment:
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD must be set}
WEBHOOK_SECRET_ENCRYPTION_KEY: ${WEBHOOK_SECRET_ENCRYPTION_KEY:?WEBHOOK_SECRET_ENCRYPTION_KEY must be set}
CYCLES_METRICS_TENANT_TAG_ENABLED: "false"
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:9980/actuator/health/readiness"]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
depends_on:
redis:
condition: service_healthy
networks:
- cycles
redis:
image: redis:7-alpine
restart: unless-stopped
environment:
REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD must be set}
command: ["sh", "-c", "redis-server --appendonly yes --requirepass \"$${REDIS_PASSWORD}\""]
volumes:
- redis-data:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli -a \"$${REDIS_PASSWORD}\" ping"]
interval: 5s
timeout: 3s
retries: 5
networks:
- cycles
volumes:
redis-data:
caddy-data:
networks:
cycles:Caddyfile (automatic HTTPS via Let's Encrypt):
admin.example.com {
reverse_proxy dashboard:80
}
Deploy:
# Generate secrets and create .env (never commit this file)
ADMIN_API_KEY="$(openssl rand -base64 32)"
REDIS_PASSWORD="$(openssl rand -base64 32)"
WEBHOOK_SECRET_ENCRYPTION_KEY="$(openssl rand -base64 32)"
DASHBOARD_ORIGIN="https://admin.example.com"
cat > .env <<EOF
ADMIN_API_KEY=$ADMIN_API_KEY
REDIS_PASSWORD=$REDIS_PASSWORD
WEBHOOK_SECRET_ENCRYPTION_KEY=$WEBHOOK_SECRET_ENCRYPTION_KEY
DASHBOARD_ORIGIN=$DASHBOARD_ORIGIN
EOF
docker compose -f docker-compose.prod.yml up -d| Concern | Development | Production |
|---|---|---|
| Dashboard URL | http://localhost:5173 |
https://admin.example.com |
| API proxy | Vite dev proxy → localhost:7979 (admin) + localhost:7878 (runtime: reservations/evidence/JWKS) |
nginx → cycles-admin:7979 + cycles-server:7878 |
| TLS | None (local only) | Required — admin key in headers |
| Admin key | Any test value | Strong random key, rotated periodically |
| Redis password | Empty (default) | Required via REDIS_PASSWORD |
| CORS origin | http://localhost:5173 |
Set DASHBOARD_ORIGIN to the public dashboard URL |
| Docker images | Built from source | Pre-built from GHCR |
| Health checks | Not needed | Dashboard liveness + backend readiness + authed Redis |
| Restart policy | None | unless-stopped |
| Ports exposed | All (5173, 7979, 6379) | Only 443/80 via TLS proxy |
- Do not expose ports 7979 or 6379 to the public internet. Only the TLS proxy (443/80) should be reachable.
- Place the admin server and Redis on an internal Docker network with no published ports.
- Use firewall rules or security groups to restrict access to the dashboard's public port by IP range if possible.
- Rotate the admin API key periodically. The key is the only credential for full system access.
- Use a strong, random key (at minimum 32 characters):
openssl rand -base64 32 - The key is stored in
sessionStorage— survives page refresh but cleared when the tab or browser is closed. Never written tolocalStorageor cookies. - Consider placing the dashboard behind SSO or VPN in addition to the API key for defense in depth.
In production, the dashboard's nginx reverse-proxies /v1/ to the backend services, so normal browser API calls are same-origin. Still set DASHBOARD_ORIGIN in docker-compose.prod.yml; compose passes it to the backend CORS allowlists for direct probes, split-host deployments, and operational consistency.
CORS actively matters when the browser talks directly to a backend service (e.g., during development with Vite's proxy, or non-standard deployments where the dashboard and API are on different origins). In that case:
- Set
DASHBOARD_ORIGIN/DASHBOARD_CORS_ORIGINto the exact dashboard URL (e.g.,https://admin.example.com). - Do not use
*— the admin server only allows the configured origin. - The admin server only permits
X-Admin-API-KeyandContent-Typeheaders through CORS.
- Always use HTTPS in production — the admin API key is transmitted as an HTTP header on every request.
- Use TLS 1.2+ with modern cipher suites. Caddy handles this automatically.
- For nginx, add:
ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers on; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
The default default.conf.template already includes these security headers:
# Security headers (included by default)
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
server_tokens off;The TLS config (nginx-ssl.conf.example) additionally includes HSTS:
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;All production assets include Subresource Integrity (SRI) hashes via vite-plugin-sri-gen.
- Production compose requires
REDIS_PASSWORD; local development compose keeps Redis unauthenticated for convenience. - Use
appendonly yesfor durability (enabled in the docker-compose above). - Do not expose Redis port (6379) outside the Docker network.
- For production, consider Redis Sentinel or Redis Cluster for high availability.
- Store
ADMIN_API_KEY,REDIS_PASSWORD, andWEBHOOK_SECRET_ENCRYPTION_KEYin a secrets manager (Vault, AWS Secrets Manager, etc.) — not in git. - Use Docker secrets or environment variable injection from your orchestrator.
- The
.envfile should be in.gitignoreand never committed.
- The backend services expose
/actuator/health/readinessfor Redis-aware readiness checks. - The dashboard's
GET /v1/admin/overviewendpoint is a good target for synthetic monitoring — if it returns 200, the entire stack (Redis + admin server + auth) is working. - Set up alerts on the overview endpoint's
failing_webhooksandover_limit_scopesarrays.
| Variable | Required | Default | Description |
|---|---|---|---|
ADMIN_API_KEY |
Yes | — | Admin API key for X-Admin-API-Key header |
REDIS_PASSWORD |
Yes in prod | (empty in dev) | Redis authentication password; required by docker-compose.prod.yml |
WEBHOOK_SECRET_ENCRYPTION_KEY |
Yes in prod | (empty in dev) | AES-256-GCM key for webhook signing secrets at rest |
DASHBOARD_ORIGIN |
Yes in prod | https://admin.example.com |
Origin used by production compose to configure backend CORS |
DASHBOARD_CORS_ORIGIN |
Dev only | http://localhost:5173 |
CORS origin — only needed when browser calls admin server directly (not via nginx proxy) |
ADMIN_UPSTREAM |
No | http://cycles-admin:7979 |
Governance-plane upstream for the dashboard container's bundled nginx proxy (/v1/* except the runtime routes below) |
RUNTIME_UPSTREAM |
No | http://cycles-server:7878 |
Runtime-plane upstream for the bundled nginx proxy (/v1/reservations/*, /v1/evidence/*, /v1/.well-known/cycles-jwks.json) |
EVIDENCE_SERVER_ID / EVIDENCE_SIGNING_SIGNER_DID |
Evidence only | (empty) | Backend cycles-server + cycles-events identity values required for signed evidence; must be byte-identical on both services. |
EVIDENCE_SIGNING_PRIVATE_KEY_HEX |
Evidence only | (empty) | Backend cycles-events private Ed25519 signing seed. Never set on cycles-server or the dashboard container. |
EVIDENCE_SIGNING_KID |
Evidence JWKS only | derived | Backend cycles-server public JWK kid label for /v1/.well-known/cycles-jwks.json; optional and not used by cycles-events. |
The dashboard itself has no application-level configuration — it's a static SPA. The two backend upstreams the bundled nginx proxy forwards to are configured via:
- Development: Vite proxy in
vite.config.ts(defaults:localhost:7979admin /localhost:7878runtime) - Production:
ADMIN_UPSTREAM/RUNTIME_UPSTREAMenv vars on the dashboard container (defaults:cycles-admin:7979/cycles-server:7878), substituted intodefault.conf.templateat start — no rebuild needed