BlueForge Docs

App Deployments (config-driven)

Config-driven deployments triggered by

forge sync
and
forge deploy
. Manages app Hetzner configs, component definitions, and the full rollback pipeline with SSE streaming.

Endpoint

MethodPathAuthIdempotent
POST
/api/app-deploys/sync
Beareryes (safe to re-run)
GET
/api/app-deploys/configs
Beareryes
GET
/api/app-deploys/configs/:slug
Beareryes
GET
/api/app-deploys/configs/:slug/components
Beareryes
POST
/api/app-deploys/configs/:slug/deployments
Bearerno
GET
/api/app-deploys/configs/:slug/deployments
Beareryes
GET
/api/app-deploys/deployments/inflight
Beareryes
GET
/api/app-deploys/deployments/:id
Beareryes
PATCH
/api/app-deploys/deployments/:id
Bearerno
GET
/api/app-deploys/configs/:slug/status
Beareryes
POST
/api/app-deploys/configs/:slug/rollback
Bearerno
GET
/api/app-deploys/configs/:slug/logs
Bearerno
POST
/api/app-deploys/configs/:slug/dry-run
Beareryes

Parameters

NameTypeRequiredDefaultNotes
slug
stringyes (path)App slug; used to scope configs, deployments, logs, and rollbacks
component
querynofirst componentComponent name filter; must match
^[a-z0-9][a-z0-9-]*$
branch
stringyes (deployment body)Git branch to deploy
commitSha
stringyes (deployment body)Git commit SHA
componentName
stringno (deployment body)
null
Deploy a specific component only
imageRef
stringnoOverride Docker image reference
triggeredBy
stringnoAudit attribution label
to
stringno (rollback body)Specific deployment ID to roll back to
force
booleanno
false
Skip the rollback safety checks
noHealthGate
booleanno
false
Skip health check before completing rollback
since
query (logs)no
"10m"
Log look-back window
follow
query (logs)noSet to
"1"
to follow log output
limit
queryno
20
Deployment history limit (max 200)

Response

// POST /api/app-deploys/sync
{
  config: {
    appSlug: string;
    server: string;
    // ... synced hetzner config fields ...
  };
  components: Array<{
    name: string;
    type: string;
    port: number;
    healthcheck: string;
    domains?: string[];
    secrets?: string[];
    env?: Record<string, unknown>;
  }>;
}

// GET /api/app-deploys/configs/:slug
{
  config: {
    appSlug: string;
    server: string;
    // ... config fields ...
  };
  components: Array<{ name: string; type: string; port: number; healthcheck: string; ... }>;
}

// POST /api/app-deploys/configs/:slug/deployments (201)
{
  id: string;
  appSlug: string;
  componentName: string | null;
  branch: string;
  commitSha: string;
  status: "queued";
  createdAt: string;
}

// GET /api/app-deploys/deployments/inflight
Array<{
  id: string;
  appSlug: string;
  status: "queued" | "building";
  branch: string;
  createdAt: string;
}>

// GET /api/app-deploys/configs/:slug/status
{
  componentName: string;
  containerStatus: string;
  health: { ok: boolean; statusCode?: number };
  lastDeployment: { id: string; status: string; createdAt: string } | null;
  history: Array<{ id: string; status: string; createdAt: string }>;
}

// POST /api/app-deploys/configs/:slug/rollback — SSE event stream
// event: step
// data: { "step": "docker-pull", "status": "running", "message": "Pulling image sha256:..." }
//
// event: done
// data: { "newDeploymentId": "dep_xxx", "status": "deployed" }

// POST /api/app-deploys/configs/:slug/dry-run
{
  infraDiff: {
    creates: Array<{ type: string; appSlug: string; component: string; port: number; healthcheck: string; domains?: string[] }>;
    updates: Array<unknown>;
    destroys: Array<unknown>;
  };
  pipelineStages: string[];
  history: Array<{ id: string; status: string; createdAt: string }>;
}

// Error responses
{ error: "Invalid payload", issues: [...] }                     // 400 (sync)
{ error: "Host \"...\" is not in the fleet" }                     // 400 (sync)
{ error: "Config not found" }                                     // 404
{ error: "branch and commitSha are required" }                    // 400 (deployment)
{ error: "Invalid component name" }                               // 400
{ error: "Invalid status", allowed: string[] }                    // 400 (PATCH)

Examples

# Sync app config (called by `forge sync`)
curl -X POST https://api.blueforge.studio/api/app-deploys/sync \
  -H "Authorization: Bearer $BF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"app": {"name": "my-app"}, "hetzner": {"server": "forge-app-1"}, "components": [...]}'

# Get app config and components
curl -H "Authorization: Bearer $BF_API_KEY" \
  https://api.blueforge.studio/api/app-deploys/configs/my-app

# Trigger a config-driven deployment
curl -X POST https://api.blueforge.studio/api/app-deploys/configs/my-app/deployments \
  -H "Authorization: Bearer $BF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"branch": "main", "commitSha": "abc123"}'

# List in-flight deployments
curl -H "Authorization: Bearer $BF_API_KEY" \
  https://api.blueforge.studio/api/app-deploys/deployments/inflight

# Get resolved deployment status
curl -H "Authorization: Bearer $BF_API_KEY" \
  https://api.blueforge.studio/api/app-deploys/configs/my-app/status

# Roll back to previous deployment (SSE stream)
curl -N -X POST https://api.blueforge.studio/api/app-deploys/configs/my-app/rollback \
  -H "Authorization: Bearer $BF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"force": true, "noHealthGate": true}'

# Preview dry-run plan
curl -X POST https://api.blueforge.studio/api/app-deploys/configs/my-app/dry-run \
  -H "Authorization: Bearer $BF_API_KEY"

# Stream container logs
curl -N -H "Authorization: Bearer $BF_API_KEY" \
  "https://api.blueforge.studio/api/app-deploys/configs/my-app/logs?component=web&since=30m&follow=1"
import { Client } from "@blueforge/client"

const client = new Client({ apiKey: process.env.BF_API_KEY })

// Trigger config-driven deployment
const deployment = await client.post(
  `/api/app-deploys/configs/my-app/deployments`,
  { branch: "main", commitSha: "abc123" }
)

// Get app context (config + components + last deployment)
const ctx = await client.get(`/api/app-deploys/configs/my-app`)

// Check in-flight deployments
const inflight = await client.get("/api/app-deploys/deployments/inflight")

// Preview dry-run plan
const plan = await client.post(`/api/app-deploys/configs/my-app/dry-run`)

Pitfalls

  • The
    POST /sync
    endpoint validates that
    payload.hetzner.server
    exists in the
    hetzner_hosts
    table. If the server is not in the fleet, the sync is rejected with a 400 — the server must be registered first.
  • The rollback pipeline streams 5 SSE events (
    step
    ,
    step
    , ...,
    done
    or
    error
    ). If the client disconnects mid-stream, the rollback continues to completion on the server but the deployment row gets a terminal
    cancelled
    status instead of
    deployed
    .
  • Rollback audit attribution uses the API key id from the auth middleware, NOT the client-supplied
    x-forge-key-id
    header (which would allow impersonation).
  • Component names must match
    ^[a-z0-9][a-z0-9-]*$
    . Invalid component names are rejected at the route layer before any processing begins.
  • The
    dry-run
    endpoint's
    infraDiff.creates
    is currently a static scaffold based on synced components. A real Pulumi preview integration is pending — the creates list reflects what would be created based on config, not actual infrastructure state.
  • The
    /configs
    listing endpoint requires a
    slug
    query param; per-slug unfiltered listing is not yet implemented (returns 400).
  • Deployment history limit is capped at 200 rows per query.

See also

Tested against

  • @blueforge/hosting-api: 2.4.1
  • Last verified: 2026-07-15