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
| Method | Path | Auth | Idempotent |
|---|
| POST | /api/app-deploys/sync
| Bearer | yes (safe to re-run) |
| GET | /api/app-deploys/configs
| Bearer | yes |
| GET | /api/app-deploys/configs/:slug
| Bearer | yes |
| GET | /api/app-deploys/configs/:slug/components
| Bearer | yes |
| POST | /api/app-deploys/configs/:slug/deployments
| Bearer | no |
| GET | /api/app-deploys/configs/:slug/deployments
| Bearer | yes |
| GET | /api/app-deploys/deployments/inflight
| Bearer | yes |
| GET | /api/app-deploys/deployments/:id
| Bearer | yes |
| PATCH | /api/app-deploys/deployments/:id
| Bearer | no |
| GET | /api/app-deploys/configs/:slug/status
| Bearer | yes |
| POST | /api/app-deploys/configs/:slug/rollback
| Bearer | no |
| GET | /api/app-deploys/configs/:slug/logs
| Bearer | no |
| POST | /api/app-deploys/configs/:slug/dry-run
| Bearer | yes |
Parameters
| Name | Type | Required | Default | Notes |
|---|
slug
| string | yes (path) | — | App slug; used to scope configs, deployments, logs, and rollbacks |
component
| query | no | first component | Component name filter; must match ^[a-z0-9][a-z0-9-]*$
|
branch
| string | yes (deployment body) | — | Git branch to deploy |
commitSha
| string | yes (deployment body) | — | Git commit SHA |
componentName
| string | no (deployment body) | null
| Deploy a specific component only |
imageRef
| string | no | — | Override Docker image reference |
triggeredBy
| string | no | — | Audit attribution label |
to
| string | no (rollback body) | — | Specific deployment ID to roll back to |
force
| boolean | no | false
| Skip the rollback safety checks |
noHealthGate
| boolean | no | false
| Skip health check before completing rollback |
since
| query (logs) | no | "10m"
| Log look-back window |
follow
| query (logs) | no | — | Set to "1" to follow log output |
limit
| query | no | 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