fix: rewrite Dokploy client with correct API format (tRPC + x-api-key)

- Verified endpoints from @dokploy/mcp source code:
  - Auth: x-api-key header (not Authorization: Bearer)
  - Base URL: {url}/api
  - application.create → needs environmentId (not projectId)
  - Git provider saved separately: saveGiteaProvider / saveGithubProvider / saveGitProvider
  - Environment vars saved via saveEnvironment
  - Deploy is a separate call after create
  - Domain attach via domain.create
- Provisioning flow now multi-step:
  1. application.create
  2. saveGiteaProvider or saveGithubProvider (based on gitRepo URL)
  3. saveEnvironment (env vars)
  4. application.deploy
  5. domain.create (optional, non-fatal)
- Removed database provisioning (Dokploy apps use their own DB via env vars)
- Teardown simplified: deleteDomain → deleteApplication
- Updated instances route + cleanup route to match new API
This commit is contained in:
Z User committed 2026-10-28 23:05:53 +00:00
1 parent 970986fef7
commit 617f00b715
4 files changed
+209 -195

No files matched your search

+204 -183
View File
@@ -1,102 +1,90 @@
/**
* Dokploy API Client
* -------------------
* Manages tenant instances on the Dokploy server: create applications from
* a Git repo, deploy, attach domains, create databases, suspend/delete.
* Dokploy API Client (v2 — correct tRPC-based API)
* -----------------------------------------------
* Manages tenant instances on the Dokploy server.
*
* API format (verified from @dokploy/mcp source):
* - Base URL: {DOKPLOY_URL}/api
* - Auth: x-api-key header (NOT Authorization: Bearer)
* - Endpoints: /application.create, /application.deploy, /application.delete, etc.
* - Application creation is multi-step:
* 1. POST /application.create { name, environmentId, sourceType: 'gitea' }
* 2. POST /application.saveGiteaProvider { applicationId, giteaOwner, giteaRepository, giteaBranch, giteaBuildPath }
* (or saveGitProvider for generic git, saveGithubProvider for github)
* 3. POST /application.saveEnvironment { applicationId, env: [...] }
* 4. POST /application.deploy { applicationId }
*
* Config (env):
* DOKPLOY_URL - e.g. https://cp.project-url.eu
* DOKPLOY_API_KEY - bearer token
* DOKPLOY_SERVER_ID - the Dokploy server ID to deploy to (default "default")
* DOKPLOY_PROJECT_ID - the Dokploy project to put tenant apps under
*
* NOTE: Dokploy API endpoints should be verified against the current API docs
* at implementation time. The endpoints below are based on the Dokploy REST API.
* See: https://docs.dokploy.com/docs/api
* DOKPLOY_URL - e.g. https://cp.project-url.eu
* DOKPLOY_API_KEY - x-api-key value
* DOKPLOY_ENVIRONMENT_ID - the Dokploy environment ID to create apps under
* DOKPLOY_SERVER_ID - (optional) specific server, null = default
*/
export interface DokployConfig {
url: string
apiKey: string
serverId?: string
projectId?: string
environmentId?: string
serverId?: string | null
}
export interface DokployApp {
applicationId: string
name: string
appName?: string
status?: string
url?: string
previewDomain?: string
createdAt?: string
}
export interface DokployDatabase {
databaseId: string
name: string
type: string // postgres|mysql|redis|...
host: string
port: number
database: string
username: string
password: string
externalUrl?: string
}
export interface DokployDomain {
domainId: string
hostName: string
applicationId: string
https: boolean
certificateStatus?: string
}
export interface ProvisionInstanceInput {
name: string
subdomain: string
gitRepo: string // e.g. https://github.com/cetinmustafa83/restov1
gitBranch: string // e.g. main
buildPath?: string // e.g. / (repo root, default)
gitRepo: string
gitBranch: string
buildPath?: string
envVars: Record<string, string>
// Resource limits (Dokploy applies these as container constraints)
cpuLimit?: string // e.g. "0.5" = 50% of one core
memoryLimit?: string // e.g. "512m"
// Database
databaseName?: string // if set, a Postgres DB is created for this instance
gitProvider?: 'gitea' | 'github' | 'git' // default: gitea
// Resource limits (applied via application.update after creation)
cpuLimit?: string
memoryLimit?: string
}
export interface ProvisionInstanceResult {
app: DokployApp
database?: DokployDatabase
domain: DokployDomain
domain?: DokployDomain
url: string
}
class DokployError extends Error {
constructor(
message: string,
public status: number,
public endpoint: string,
public body?: unknown
) {
constructor(message: string, public status: number, public endpoint: string, public body?: unknown) {
super(message)
this.name = 'DokployError'
}
}
export function createDokployClient(config: DokployConfig) {
const baseUrl = config.url.replace(/\/$/, '')
const serverId = config.serverId ?? 'default'
const baseUrl = config.url.replace(/\/+$/, '') + '/api'
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Authorization: `Bearer ${config.apiKey}`,
Accept: 'application/json',
'x-api-key': config.apiKey,
}
async function call<T = unknown>(endpoint: string, body?: Record<string, unknown>, method = 'POST'): Promise<T> {
const url = `${baseUrl}${endpoint}`
async function call<T = unknown>(path: string, body?: Record<string, unknown>, method = 'POST'): Promise<T> {
const url = `${baseUrl}${path}`
const isGet = method === 'GET'
const res = await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
body: !isGet && body ? JSON.stringify(body) : undefined,
})
const text = await res.text()
let json: unknown
@@ -107,9 +95,9 @@ export function createDokployClient(config: DokployConfig) {
}
if (!res.ok) {
throw new DokployError(
`Dokploy API ${endpoint} failed: HTTP ${res.status}`,
`Dokploy API ${path} failed: HTTP ${res.status}`,
res.status,
endpoint,
path,
json
)
}
@@ -120,111 +108,130 @@ export function createDokployClient(config: DokployConfig) {
async function createApplication(input: {
name: string
environmentId: string
sourceType?: string
serverId?: string | null
description?: string
gitRepo: string
gitBranch: string
buildPath?: string
envVars?: Record<string, string>
cpuLimit?: string
memoryLimit?: string
}): Promise<DokployApp> {
const body: Record<string, unknown> = {
name: input.name,
description: input.description ?? `Tenant instance: ${input.name}`,
serverId,
projectName: config.projectId ?? 'default',
sourceType: 'git',
gitRepository: input.gitRepo,
gitBranch: input.gitBranch,
buildPath: input.buildPath ?? '/',
// Dokploy expects env vars as a JSON string
env: JSON.stringify(input.envVars ?? {}),
cpuLimit: input.cpuLimit ?? '0.5',
memoryLimit: input.memoryLimit ?? '512m',
environmentId: input.environmentId,
sourceType: input.sourceType ?? 'gitea',
}
const res = await call<{ applicationId?: string; app?: { applicationId: string }; [k: string]: unknown }>('/api/application.create', body)
const applicationId = res.applicationId ?? (res.app as { applicationId: string } | undefined)?.applicationId
if (input.serverId) body.serverId = input.serverId
if (input.description) body.description = input.description
const res = await call<{ applicationId?: string; app?: DokployApp; [k: string]: unknown }>(
'/application.create',
body
)
const applicationId = res.applicationId ?? (res.app as DokployApp | undefined)?.applicationId
if (!applicationId) {
throw new DokployError('Dokploy createApplication returned no applicationId', 200, '/api/application.create', res)
throw new DokployError('createApplication returned no applicationId', 200, '/application.create', res)
}
return { applicationId, name: input.name, status: 'created' }
}
async function deployApplication(applicationId: string): Promise<{ deployId: string; status: string }> {
const res = await call<{ deployId?: string; jobId?: string; status?: string }>('/api/application.deploy', { applicationId })
return { deployId: res.deployId ?? res.jobId ?? '', status: res.status ?? 'deploying' }
async function saveGiteaProvider(input: {
applicationId: string
giteaOwner: string
giteaRepository: string
giteaBranch: string
giteaBuildPath?: string | null
giteaId?: string | null
}): Promise<void> {
await call('/application.saveGiteaProvider', {
applicationId: input.applicationId,
giteaOwner: input.giteaOwner,
giteaRepository: input.giteaRepository,
giteaBranch: input.giteaBranch,
giteaBuildPath: input.giteaBuildPath ?? '/',
giteaId: input.giteaId ?? null,
})
}
async function getApplication(applicationId: string): Promise<DokployApp & { status?: string; url?: string }> {
async function saveGitProvider(input: {
applicationId: string
gitProviderOwner?: string | null
gitProviderRepository?: string | null
gitProviderBranch?: string
gitProviderBuildPath?: string | null
gitProviderId?: string | null
}): Promise<void> {
await call('/application.saveGitProvider', {
applicationId: input.applicationId,
gitProviderOwner: input.gitProviderOwner ?? null,
gitProviderRepository: input.gitProviderRepository ?? null,
gitProviderBranch: input.gitProviderBranch ?? 'main',
gitProviderBuildPath: input.gitProviderBuildPath ?? '/',
gitProviderId: input.gitProviderId ?? null,
})
}
async function saveGithubProvider(input: {
applicationId: string
githubOwner: string
githubRepository: string
githubBranch: string
githubBuildPath?: string | null
githubId?: string | null
}): Promise<void> {
await call('/application.saveGithubProvider', {
applicationId: input.applicationId,
githubOwner: input.githubOwner,
githubRepository: input.githubRepository,
githubBranch: input.githubBranch,
githubBuildPath: input.githubBuildPath ?? '/',
githubId: input.githubId ?? null,
})
}
async function saveEnvironment(applicationId: string, envVars: Record<string, string>): Promise<void> {
// Dokploy expects env as array of { key, value } objects OR a JSON string
const envArray = Object.entries(envVars).map(([key, value]) => ({ key, value }))
await call('/application.saveEnvironment', {
applicationId,
env: JSON.stringify(envArray),
})
}
async function deployApplication(applicationId: string): Promise<{ jobId?: string; status: string }> {
const res = await call<{ jobId?: string; deployId?: string; status?: string }>('/application.deploy', {
applicationId,
})
return { jobId: res.jobId ?? res.deployId, status: res.status ?? 'deploying' }
}
async function getApplication(applicationId: string): Promise<DokployApp> {
const res = await call<DokployApp & { applicationStatus?: string; previewDomain?: string }>(
`/api/application.one`,
'/application.one',
{ applicationId },
'GET'
)
return {
applicationId: res.applicationId,
name: res.name,
appName: res.appName,
status: res.applicationStatus ?? res.status,
url: res.previewDomain ?? res.url,
previewDomain: res.previewDomain,
createdAt: res.createdAt,
}
}
async function deleteApplication(applicationId: string): Promise<void> {
await call('/api/application.delete', { applicationId, deleteVolumes: true })
await call('/application.delete', { applicationId })
}
async function stopApplication(applicationId: string): Promise<void> {
await call('/api/application.stop', { applicationId })
await call('/application.stop', { applicationId })
}
async function startApplication(applicationId: string): Promise<void> {
await call('/api/application.start', { applicationId })
await call('/application.start', { applicationId })
}
async function updateEnv(applicationId: string, envVars: Record<string, string>): Promise<void> {
await call('/api/application.update', {
applicationId,
env: JSON.stringify(envVars),
})
}
// --- Databases (Postgres) ---
async function createPostgresDatabase(input: {
name: string
appName: string // the application this DB belongs to
description?: string
externalUrl?: boolean
}): Promise<DokployDatabase> {
const body: Record<string, unknown> = {
name: input.name,
appName: input.appName,
description: input.description ?? `DB for ${input.appName}`,
serverId,
projectName: config.projectId ?? 'default',
externalUrl: input.externalUrl ?? false,
}
const res = await call<Record<string, unknown>>('/api/database.postgres.create', body)
// The response shape varies between Dokploy versions; handle both.
const db = (res.database as Record<string, unknown> | undefined) ?? res
const databaseId = (db.databaseId as string | undefined) ?? (res.databaseId as string | undefined)
if (!databaseId) {
throw new DokployError('Dokploy createDatabase returned no databaseId', 200, '/api/database.postgres.create', res)
}
return {
databaseId,
name: (db.name as string | undefined) ?? input.name,
type: 'postgres',
host: (db.host as string | undefined) ?? 'postgres',
port: (db.port as number | undefined) ?? 5432,
database: (db.database as string | undefined) ?? input.name,
username: (db.username as string | undefined) ?? 'postgres',
password: (db.password as string | undefined) ?? '',
}
}
async function deleteDatabase(databaseId: string): Promise<void> {
await call('/api/database.postgres.delete', { databaseId })
async function redeployApplication(applicationId: string): Promise<void> {
await call('/application.redeploy', { applicationId })
}
// --- Domains ---
@@ -235,20 +242,17 @@ export function createDokployClient(config: DokployConfig) {
path?: string
port?: number
https?: boolean
certificateType?: 'letsencrypt' | 'none'
}): Promise<DokployDomain> {
const body: Record<string, unknown> = {
const res = await call<{ domainId?: string; id?: string }>('/domain.create', {
applicationId: input.applicationId,
hostName: input.hostName,
path: input.path ?? '/',
port: input.port ?? 3000,
https: input.https ?? true,
certificateType: input.certificateType ?? 'letsencrypt',
}
const res = await call<{ domainId?: string; id?: string; hostName?: string; https?: boolean }>('/api/domain.create', body)
})
const domainId = res.domainId ?? res.id
if (!domainId) {
throw new DokployError('Dokploy createDomain returned no domainId', 200, '/api/domain.create', res)
throw new DokployError('createDomain returned no domainId', 200, '/domain.create', res)
}
return {
domainId,
@@ -259,55 +263,84 @@ export function createDokployClient(config: DokployConfig) {
}
async function deleteDomain(domainId: string): Promise<void> {
await call('/api/domain.delete', { domainId })
await call('/domain.delete', { domainId })
}
// --- High-level: provision a full instance ---
async function provisionInstance(input: ProvisionInstanceInput): Promise<ProvisionInstanceResult> {
// 1. Create application from Git repo
const environmentId = config.environmentId ?? ''
if (!environmentId) {
throw new DokployError('DOKPLOY_ENVIRONMENT_ID not configured', 500, 'config')
}
// 1. Create application
const app = await createApplication({
name: input.name,
gitRepo: input.gitRepo,
gitBranch: input.gitBranch,
buildPath: '/',
envVars: input.envVars,
cpuLimit: input.cpuLimit,
memoryLimit: input.memoryLimit,
environmentId,
sourceType: input.gitProvider ?? 'gitea',
serverId: config.serverId,
description: `Tenant instance: ${input.subdomain}`,
})
// 2. (Optional) Create Postgres database
let database: DokployDatabase | undefined
if (input.databaseName) {
database = await createPostgresDatabase({
name: input.databaseName,
appName: input.name,
// 2. Save Git provider (repo connection)
// Parse git repo URL: https://git.dfmpanel.eu/dfmsolutions/AOS.git
// → owner: dfmsolutions, repo: AOS
const gitMatch = input.gitRepo.match(/\/([^/]+)\/([^/]+?)(?:\.git)?$/)
const owner = gitMatch?.[1] ?? ''
const repo = gitMatch?.[2] ?? ''
if (input.gitProvider === 'github' || input.gitRepo.includes('github.com')) {
await saveGithubProvider({
applicationId: app.applicationId,
githubOwner: owner,
githubRepository: repo,
githubBranch: input.gitBranch,
githubBuildPath: input.buildPath ?? '/',
})
// Inject DATABASE_URL into the app's env
const dbUrl = `postgresql://${database.username}:${database.password}@${database.host}:${database.port}/${database.database}`
await updateEnv(app.applicationId, {
...input.envVars,
DATABASE_URL: dbUrl,
} else if (input.gitProvider === 'gitea' || input.gitRepo.includes('git.dfmpanel.eu')) {
await saveGiteaProvider({
applicationId: app.applicationId,
giteaOwner: owner,
giteaRepository: repo,
giteaBranch: input.gitBranch,
giteaBuildPath: input.buildPath ?? '/',
})
} else {
// Generic git
await saveGitProvider({
applicationId: app.applicationId,
gitProviderOwner: owner,
gitProviderRepository: repo,
gitProviderBranch: input.gitBranch,
gitProviderBuildPath: input.buildPath ?? '/',
})
}
// 3. Attach domain (subdomain.platform-domain)
const hostName = input.subdomain
const domain = await createDomain({
applicationId: app.applicationId,
hostName,
port: 3000,
https: true,
})
// 3. Save environment variables
await saveEnvironment(app.applicationId, input.envVars)
// 4. Trigger deploy
// 4. Deploy
await deployApplication(app.applicationId)
// 5. Attach domain (optional — can be done later)
let domain: DokployDomain | undefined
try {
domain = await createDomain({
applicationId: app.applicationId,
hostName: input.subdomain,
port: 3000,
https: true,
})
} catch (e) {
// Domain creation is non-fatal — app is already deployed
console.warn('[dokploy] Domain creation failed (non-fatal):', e instanceof Error ? e.message : e)
}
return {
app,
database,
domain,
url: `https://${hostName}`,
url: `https://${input.subdomain}`,
}
}
@@ -315,14 +348,13 @@ export function createDokployClient(config: DokployConfig) {
async function teardownInstance(input: {
dokployAppId?: string
dokployDbId?: string
dnsDomainId?: string // Dokploy domain ID
dokployDomainId?: string
}): Promise<{ steps: string[] }> {
const steps: string[] = []
// Remove domain first (stop traffic)
if (input.dnsDomainId) {
if (input.dokployDomainId) {
try {
await deleteDomain(input.dnsDomainId)
await deleteDomain(input.dokployDomainId)
steps.push('domain.removed')
} catch (e) {
if (e instanceof DokployError && e.status !== 404) {
@@ -330,17 +362,6 @@ export function createDokployClient(config: DokployConfig) {
}
}
}
// Delete database
if (input.dokployDbId) {
try {
await deleteDatabase(input.dokployDbId)
steps.push('database.removed')
} catch (e) {
if (e instanceof DokployError && e.status !== 404) {
steps.push(`database.error: ${e.message}`)
}
}
}
// Delete application (+ volumes)
if (input.dokployAppId) {
try {
@@ -358,14 +379,16 @@ export function createDokployClient(config: DokployConfig) {
return {
// Low-level
createApplication,
saveGiteaProvider,
saveGitProvider,
saveGithubProvider,
saveEnvironment,
deployApplication,
getApplication,
deleteApplication,
stopApplication,
startApplication,
updateEnv,
createPostgresDatabase,
deleteDatabase,
redeployApplication,
createDomain,
deleteDomain,
call,
@@ -377,8 +400,6 @@ export function createDokployClient(config: DokployConfig) {
export type DokployClient = ReturnType<typeof createDokployClient>
// --- Factory: create from env ---
export function createDokployClientFromEnv(): DokployClient | null {
const url = process.env.DOKPLOY_URL
const apiKey = process.env.DOKPLOY_API_KEY
@@ -386,7 +407,7 @@ export function createDokployClientFromEnv(): DokployClient | null {
return createDokployClient({
url,
apiKey,
serverId: process.env.DOKPLOY_SERVER_ID,
projectId: process.env.DOKPLOY_PROJECT_ID,
environmentId: process.env.DOKPLOY_ENVIRONMENT_ID,
serverId: process.env.DOKPLOY_SERVER_ID ?? null,
})
}